hermes-bsd/scripts/install.ps1

2369 lines
105 KiB
PowerShell
Raw Normal View History

feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# ============================================================================
# Hermes Agent Installer for Windows
# ============================================================================
# Installation script for Windows (PowerShell).
# Uses uv for fast Python provisioning and package management.
#
# Usage:
# iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
#
# Or download and run with options:
# .\install.ps1 -NoVenv -SkipSetup
#
# ============================================================================
param(
[switch]$NoVenv,
[switch]$SkipSetup,
[string]$Branch = "main",
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# -Commit and -Tag are higher-precedence variants of -Branch for users
# who need reproducible installs (desktop installer pinning, CI, release
# bundles). When set, the repository stage clones $Branch (faster than
# cloning the full default-branch history) and then `git checkout`s the
# exact ref. Precedence: Commit > Tag > Branch.
[string]$Commit = "",
[string]$Tag = "",
[string]$HermesHome = "$env:LOCALAPPDATA\hermes",
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
[string]$InstallDir = "$env:LOCALAPPDATA\hermes\hermes-agent",
# --- Stage protocol (additive; default invocation behaves as before) ----
# See the "Stage protocol" section near the bottom of the file for the
# full contract. Intended for programmatic drivers (the desktop GUI's
# onboarding wizard, CI, future install.sh parity, etc.). CLI users
# running the canonical `irm | iex` one-liner never touch these flags.
[switch]$Manifest,
[string]$Stage,
[switch]$ProtocolVersion,
[switch]$NonInteractive,
[switch]$Json,
# --- Ensure mode (dep_ensure.py entry point) ---
[string]$Ensure = "",
[switch]$PostInstall
)
$ErrorActionPreference = "Stop"
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell
# 5.1's progress UI repaints synchronously on every received byte, which
# pegs CPU on a single core and throttles downloads by 10-100x (a 57MB
# PortableGit grab can take 5 minutes with progress on vs 20 seconds
# with progress off, on the same network). Every IWR call in this
# script is fire-and-forget so we never need to see the bar. Restored
# automatically when the script exits.
$ProgressPreference = "SilentlyContinue"
# Force the console to UTF-8 so non-ASCII output from native commands
# (e.g. playwright's box-drawing progress bars and download banners,
# git's bullet glyphs, npm's check marks) renders correctly instead of
# as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box-
# drawing chars decoded under the legacy DOS codepage). This is a
# DISPLAY-only fix; the underlying bytes are already correct. We do
# NOT change the file's own encoding (it remains pure ASCII for PS 5.1
# parser compatibility; see comments at the top of the entry-point
# dispatch). This affects only what the user sees in their terminal
# during this install run, and reverts automatically when the script
# exits and the host's console encoding is restored.
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
} catch {
# Some constrained PowerShell hosts disallow encoding mutation.
# Mojibake on output is then cosmetic-only, install still works.
}
# ============================================================================
# Configuration
# ============================================================================
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
$PythonVersion = "3.11"
$NodeVersion = "22"
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Stage-protocol version. Bumped only for genuinely breaking changes to the
# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a
# new stage does NOT bump this -- drivers iterate the manifest dynamically.
$InstallStageProtocolVersion = 1
# ============================================================================
# Helper functions
# ============================================================================
function Write-Banner {
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host ""
}
function Write-Info {
param([string]$Message)
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "-> $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "[OK] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "[!] $Message" -ForegroundColor Yellow
}
function Write-Err {
param([string]$Message)
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "[X] $Message" -ForegroundColor Red
}
# --- Ensure-mode helpers ---
function Resolve-NpmCmd {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) { return $null }
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) { return $npmCmdSibling }
}
return $npmExe
}
function Find-SystemBrowser {
$candidates = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe",
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"${env:ProgramFiles}\Chromium\Application\chrome.exe",
"${env:LOCALAPPDATA}\Chromium\Application\chrome.exe"
)
foreach ($p in $candidates) {
if (Test-Path $p) { return $p }
}
return $null
}
function Write-BrowserEnv {
param([string]$BrowserPath)
if (-not (Test-Path $HermesHome)) {
New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null
}
$envFile = Join-Path $HermesHome ".env"
if (-not (Test-Path $envFile)) {
Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
return
}
$content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue
if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return }
Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
}
function Install-AgentBrowser {
param([switch]$SkipChromium)
$npm = Resolve-NpmCmd
if (-not $npm) {
Write-Err "npm not found -- install Node.js first"
throw "npm not found"
}
Write-Info "Installing agent-browser via npm -g --prefix..."
$prefixDir = Join-Path $HermesHome "node"
if (-not (Test-Path $prefixDir)) {
New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null
}
$npmLog = [System.IO.Path]::GetTempFileName()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null
$npmExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($npmExit -ne 0) {
$npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
Write-Err "npm install -g failed (exit $npmExit): $npmDetail"
throw "npm install failed"
}
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
if (-not $SkipChromium) {
$sysBrowser = Find-SystemBrowser
if ($sysBrowser) {
Write-BrowserEnv -BrowserPath $sysBrowser
Write-Info "System browser detected -- skipping Chromium download"
} else {
$abExe = Join-Path $prefixDir "agent-browser.cmd"
if (Test-Path $abExe) {
Write-Info "Installing Chromium via agent-browser install..."
$abLog = [System.IO.Path]::GetTempFileName()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null
$abExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($abExit -ne 0) {
$abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue
Write-Warn "Chromium install failed (exit $abExit): $abDetail"
}
Remove-Item $abLog -Force -ErrorAction SilentlyContinue
} else {
Write-Warn "agent-browser.cmd not found at $abExe"
}
}
}
Write-Success "Agent-browser ready"
}
# ============================================================================
# Dependency checks
# ============================================================================
function Install-Uv {
Write-Info "Checking for uv package manager..."
# Check if uv is already available
if (Get-Command uv -ErrorAction SilentlyContinue) {
$version = uv --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
return $true
}
# Check common install locations
$uvPaths = @(
"$env:USERPROFILE\.local\bin\uv.exe",
"$env:USERPROFILE\.cargo\bin\uv.exe"
)
foreach ($uvPath in $uvPaths) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
$version = & $uvPath --version
Write-Success "uv found at $uvPath ($version)"
return $true
}
}
# Install uv
Write-Info "Installing uv (fast Python package manager)..."
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value -- if the assignment lived inside try and the
# try body threw before reaching it, the catch would see $prevEAP
# unset and leave EAP at whatever the previous protected call set.
$prevEAP = $ErrorActionPreference
try {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Relax ErrorActionPreference around the nested astral installer.
# The astral installer (a separate `powershell -c "irm ... | iex"`)
# writes download progress to stderr. With $ErrorActionPreference
# = "Stop" set at the top of this script, PowerShell wraps stderr
# lines from native commands (which `powershell -c` is, from our
# perspective) as ErrorRecord objects when captured via 2>&1, then
# throws a terminating exception on the first one -- even though
# uv installs successfully and the child exits 0. Same fix
# pattern Test-Python uses for `uv python install`; verify success
# via Test-Path on the expected binary afterwards, which is more
# reliable than exit-code/stderr signal anyway.
$ErrorActionPreference = "Continue"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$ErrorActionPreference = $prevEAP
# Find the installed binary
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
if (-not (Test-Path $uvExe)) {
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
}
if (-not (Test-Path $uvExe)) {
# Refresh PATH and try again
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$uvExe = (Get-Command uv).Source
}
}
if (Test-Path $uvExe) {
$script:UvCmd = $uvExe
$version = & $uvExe --version
Write-Success "uv installed ($version)"
return $true
}
Write-Err "uv installed but not found on PATH"
Write-Info "Try restarting your terminal and re-running"
return $false
} catch {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
}
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Refresh $env:Path from the User + Machine registry hives. Stage drivers
# invoke each stage in a fresh powershell process, but those processes
# inherit env from the parent driver shell, NOT from the registry. When
# an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and
# pushes its directory into User PATH, the next child process's $env:Path
# is stale and the binary appears missing. This helper re-reads PATH
# from the registry so every Invoke-Stage starts from a fresh, up-to-date
# PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent.
function Sync-EnvPath {
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
}
# Re-discover uv without re-installing it. Cross-process stage drivers
# (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from PATH or known install paths.
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch. Fast path is a single
# Get-Command call when uv is on PATH (the common case after Stage-Uv
# ran path-modifying installs in a sibling process).
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
# in the same process and set $script:UvCmd).
if ($script:UvCmd) {
if ($script:UvCmd -eq "uv") {
# "uv" on PATH -- verify it's still resolvable (PATH could have
# changed mid-session; cheap to recheck).
if (Get-Command uv -ErrorAction SilentlyContinue) { return }
} elseif (Test-Path $script:UvCmd) {
return
}
# Stale; fall through to re-discover.
}
# Try PATH first (covers `winget install astral.uv`, manual installs,
# and the post-Install-Uv state where uv.exe lives in
# %USERPROFILE%\.local\bin which the installer added to PATH).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
# Refresh PATH from registry in case the current process started before
# Install-Uv updated User PATH.
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
# Check the well-known install locations the astral.sh installer drops
# uv into. Mirrors the probe order Install-Uv uses.
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
return
}
}
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
}
function Test-Python {
Write-Info "Checking Python $PythonVersion..."
# Let uv find or install Python
try {
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
Write-Success "Python found: $ver"
return $true
}
} catch { }
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Python not found -- use uv to install it (no admin needed!)
Write-Info "Python $PythonVersion not found, installing via uv..."
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
try {
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
# Temporarily relax ErrorActionPreference: uv writes download progress
# ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to
# stderr. With $ErrorActionPreference = "Stop" (set at the top of this
# script) PowerShell wraps stderr lines from native commands as
# ErrorRecord objects when captured via 2>&1, then throws a terminating
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# exception on the first one -- even though uv exits 0 and Python was
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
# installed successfully. Verify success via `uv python find`
# afterwards, which is the reliable signal regardless of exit-code
# semantics or stderr noise. This fix was previously landed as
# commit ec1714e71 and then lost in a release squash; reapplied here.
$ErrorActionPreference = "Continue"
$uvOutput = & $UvCmd python install $PythonVersion 2>&1
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
$uvExitCode = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
# Check if Python is now available (more reliable than exit code
# since uv may return non-zero due to "already installed" etc.)
$pythonPath = & $UvCmd python find $PythonVersion 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
Write-Success "Python installed: $ver"
return $true
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# uv ran but Python still not findable -- show what happened
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
if ($uvExitCode -ne 0) {
Write-Warn "uv python install output:"
Write-Host $uvOutput -ForegroundColor DarkGray
}
} catch {
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Warn "uv python install error: $_"
}
# Fallback: check if ANY Python 3.10+ is already available on the system
Write-Info "Trying to find any existing Python 3.10+..."
foreach ($fallbackVer in @("3.12", "3.13", "3.10")) {
try {
$pythonPath = & $UvCmd python find $fallbackVer 2>$null
if ($pythonPath) {
$ver = & $pythonPath --version 2>$null
Write-Success "Found fallback: $ver"
$script:PythonVersion = $fallbackVer
return $true
}
} catch { }
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Fallback: try system python -- but skip the Microsoft Store stub.
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
# On Windows, %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe is a 0-byte
# reparse-point stub that prints "Python was not found; run without
# arguments to install from the Microsoft Store..." to stdout and exits
# non-zero. Get-Command finds it; invoking it produces a confusing error
# that the user sees as our installer crashing.
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$isStoreStub = $false
try {
$pythonSource = $pythonCmd.Source
if ($pythonSource -and $pythonSource -like "*\WindowsApps\*") {
$isStoreStub = $true
} else {
# Even outside WindowsApps, a 0-byte file is the stub
$item = Get-Item $pythonSource -ErrorAction SilentlyContinue
if ($item -and $item.Length -eq 0) { $isStoreStub = $true }
}
} catch { }
if (-not $isStoreStub) {
try {
$prevEAP2 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$sysVer = & python --version 2>&1
$ErrorActionPreference = $prevEAP2
if ($sysVer -match "Python 3\.(1[0-9]|[1-9][0-9])") {
Write-Success "Using system Python: $sysVer"
return $true
}
} catch {
if ($prevEAP2) { $ErrorActionPreference = $prevEAP2 }
}
}
}
fix(install.ps1): restore EAP=Continue around uv python install, skip Store stub (#26586) Fresh Windows installs were failing on first run with: ⚠ uv python install error: Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB) ✗ Installation failed: Python was not found; run without arguments to install from the Microsoft Store... Two bugs compounding: 1) EAP=Stop swallows uv's stderr progress as an exception. uv writes download progress ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to stderr. With $ErrorActionPreference = "Stop" set at the top of the script plus 2>&1 capture, PowerShell wraps each stderr line as an ErrorRecord and throws on the first one — even though uv exits 0 and Python was installed successfully. This was previously fixed in commit ec1714e71 (May 8) but lost in the May 12 release squash (413990c94). Reapply the EAP=Continue + verify-via 'uv python find' pattern. 2) System-python fallback invokes the Microsoft Store stub. When the uv paths fall through, the legacy 'python --version' check invokes %LOCALAPPDATA%\\Microsoft\\WindowsApps\\python.exe, a 0-byte reparse-point stub that prints 'Python was not found...' to stdout and exits non-zero. Get-Command matches it. The resulting error message is what the user sees as the final installer crash. Detect and skip the stub by checking for the \\WindowsApps\\ path component or a 0-byte file size before invoking python. Also save/restore EAP defensively in the catch blocks so a throw before the assignment can't leave EAP in 'Continue'.
2026-05-15 14:07:56 -07:00
Write-Err "Failed to install Python $PythonVersion"
Write-Info "Install Python 3.11 manually, then re-run this script:"
Write-Info " https://www.python.org/downloads/"
Write-Info " Or: winget install Python.Python.3.11"
return $false
}
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
function Install-Git {
<#
.SYNOPSIS
Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe
which Hermes uses to run shell commands.
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Priority order (deliberately simple -- no winget, no registry, no system
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
package manager):
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
1. Existing ``git`` on PATH -- use it as-is (the common fast path).
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
2. Download **PortableGit** from the official git-for-windows GitHub
release (self-extracting 7z.exe) and unpack it to
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
``%LOCALAPPDATA%\hermes\git`` -- never touches system Git, never
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
requires admin, works even on locked-down machines and machines
with a broken system Git install.
**Why PortableGit, not MinGit:** MinGit is the minimal-automation
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
distribution and ships ONLY ``git.exe`` -- no bash, no POSIX utilities.
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
Hermes needs ``bash.exe`` to run shell commands. PortableGit is the
full Git for Windows distribution without the installer UI; it ships
``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``,
``ssh``, etc. in ``usr\bin\``.
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
We deliberately skip winget because it fails badly when the system Git
install is in a half-installed state (partially registered, or uninstall-
blocked). Owning the Hermes copy of Git ourselves is predictable and
recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
and re-running this installer fully recovers.
After install we locate ``bash.exe`` and persist the path in
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
shell without a second PATH refresh.
#>
Write-Info "Checking Git..."
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
if (Get-Command git -ErrorAction SilentlyContinue) {
$version = git --version
Write-Success "Git found ($version)"
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
Set-GitBashEnvVar
return $true
}
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# Download PortableGit into $HermesHome\git. Always works as long as
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# we can reach github.com -- no admin, no winget, no reliance on the
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# user's possibly-broken system Git install.
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Info "Git not found -- downloading PortableGit to $HermesHome\git\ ..."
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
Write-Info "(no admin rights required; isolated from any system Git install)"
try {
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
$arch = if ([Environment]::Is64BitOperatingSystem) {
# Detect ARM64 vs x64 explicitly; PortableGit ships separate assets.
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
"arm64"
} else {
"64-bit"
}
} else {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# PortableGit does not ship a 32-bit build -- fall back to MinGit 32-bit
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# with a warning that bash-based features will be unavailable.
"32-bit-mingit"
}
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest"
$release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" }
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
if ($arch -eq "32-bit-mingit") {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "32-bit Windows detected -- PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine."
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
$assetPattern = "MinGit-*-32-bit.zip"
$downloadIsZip = $true
} elseif ($arch -eq "arm64") {
$assetPattern = "PortableGit-*-arm64.7z.exe"
$downloadIsZip = $false
} else {
$assetPattern = "PortableGit-*-64-bit.7z.exe"
$downloadIsZip = $false
}
$asset = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -First 1
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
if (-not $asset) {
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
throw "Could not find $assetPattern in latest git-for-windows release"
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
}
$downloadUrl = $asset.browser_download_url
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
$downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" }
$tmpFile = "$env:TEMP\$($asset.name)"
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$gitDir = "$HermesHome\git"
Write-Info "Downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..."
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
if (Test-Path $gitDir) {
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
Write-Info "Removing previous Git install at $gitDir ..."
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
Remove-Item -Recurse -Force $gitDir
}
New-Item -ItemType Directory -Path $gitDir -Force | Out-Null
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
if ($downloadIsZip) {
Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force
} else {
# PortableGit is a self-extracting 7z archive. Invoke it with
# `-o<target> -y` (silent) to extract to $gitDir. No 7z install
# required; it's fully self-contained.
Write-Info "Extracting PortableGit to $gitDir ..."
$extractProc = Start-Process -FilePath $tmpFile `
-ArgumentList "-o`"$gitDir`"", "-y" `
-NoNewWindow -Wait -PassThru
if ($extractProc.ExitCode -ne 0) {
throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))"
}
}
Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue
# PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils)
# MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present)
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$gitExe = "$gitDir\cmd\git.exe"
if (-not (Test-Path $gitExe)) {
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
throw "Git extraction did not produce git.exe at $gitExe"
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
}
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# Add to session PATH so the rest of this install run can use git.
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$env:Path = "$gitDir\cmd;$env:Path"
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# Persist to User PATH so fresh shells see it. PortableGit needs
# cmd\ (for git.exe), bin\ (for bash.exe + core tools), and
# usr\bin\ (for perl, ssh, curl, and other POSIX coreutils).
$newPathEntries = @(
"$gitDir\cmd",
"$gitDir\bin",
"$gitDir\usr\bin"
)
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
$changed = $false
foreach ($entry in $newPathEntries) {
if ($userPathItems -notcontains $entry) {
$userPathItems += $entry
$changed = $true
}
}
if ($changed) {
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
}
$version = & $gitExe --version
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
Set-GitBashEnvVar
return $true
} catch {
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
Write-Err "Could not install portable Git: $_"
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
Write-Info ""
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run"
Write-Info "shell commands (same as Claude Code and other coding agents)."
return $false
}
}
function Set-GitBashEnvVar {
<#
.SYNOPSIS
Locate ``bash.exe`` from an already-installed Git and persist the path in
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
PATH propagation completes in a newly-spawned shell.
#>
$candidates = @()
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
# Our own portable Git install is ALWAYS checked first, so a broken
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
# system Git doesn't hijack us. If the user had a working system Git
# we'd have returned early from Install-Git's fast path and never called
# this with a system-Git-only installation anyway.
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
#
# Layouts:
# PortableGit (our default): $HermesHome\git\bin\bash.exe
# MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe
$candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary)
$candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
# git.exe on PATH can tell us where the install root is
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if ($gitCmd) {
$gitExe = $gitCmd.Source
# Git for Windows (full installer): <root>\cmd\git.exe + <root>\bin\bash.exe
# MinGit: <root>\cmd\git.exe + <root>\usr\bin\bash.exe
$gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
$candidates += "$gitRoot\bin\bash.exe"
$candidates += "$gitRoot\usr\bin\bash.exe"
}
# Standard system install locations as a final fallback. Note:
# ProgramFiles(x86) can't be referenced via ${env:...} string interpolation
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# because of the parens -- use [Environment]::GetEnvironmentVariable().
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
$candidates += "${env:ProgramFiles}\Git\bin\bash.exe"
$pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)")
if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" }
$candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe"
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
$env:HERMES_GIT_BASH_PATH = $candidate
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
return
}
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Could not locate bash.exe -- Hermes may not find Git Bash."
feat(windows-install): bundle portable MinGit instead of relying on winget User hit a real failure case: their system Git was in a half-installed state (can neither uninstall nor reinstall) and winget refused to work around it. We were one step away from shipping an installer that would have left users with exactly the problem he already had. What other agents do (reality check): - Claude Code: requires pre-installed Git; breaks if user doesn't have it. - OpenCode, Codex: don't need bash at all — PowerShell-first design. - Cline: uses whatever shell VSCode is configured with; installs nothing. None of them solve the "broken system Git" problem. We need to own our Git. Changes: - scripts/install.ps1::Install-Git: dropped winget path entirely. Now: (1) use existing git if present; (2) download portable MinGit from the official git-for-windows GitHub release to %LOCALAPPDATA%\hermes\git. No winget, no admin, no Windows installer registry, no system impact. - Added %LOCALAPPDATA%\hermes\git\{cmd,usr\bin} to User PATH so git + bash + POSIX coreutils (which, env, grep, …) resolve in fresh shells. - tools/environments/local.py::_find_bash: reorder so Hermes' portable MinGit install is checked BEFORE falling through to shutil.which("bash") or system install locations. This way a broken system Git can't hijack the bash lookup. - README + installation docs reworded to reflect the new story: "portable Git Bash, isolated from any system install, recoverable via rm -rf if it ever breaks." Recoverability: if Hermes' Git install ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git`` and re-run the installer — no system impact, no uninstall drama, no winget to fight with.
2026-05-07 16:38:11 -07:00
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
function Test-Node {
Write-Info "Checking Node.js (for browser tools)..."
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
Write-Success "Node.js $version found"
$script:HasNode = $true
return $true
}
# Check our own managed install from a previous run
$managedNode = "$HermesHome\node\node.exe"
if (Test-Path $managedNode) {
$version = & $managedNode --version
$env:Path = "$HermesHome\node;$env:Path"
Write-Success "Node.js $version found (Hermes-managed)"
$script:HasNode = $true
return $true
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Info "Node.js not found -- installing Node.js $NodeVersion LTS..."
# Try the portable-zip path FIRST -- no UAC, no admin, no winget MSI.
# winget install OpenJS.NodeJS.LTS triggers a system-wide MSI install
# which prompts UAC (the dialog often appears minimized in the taskbar
# and the install silently waits for consent, looking like a hang).
# The portable zip path drops node.exe + npm into $HermesHome\node\
# which is user-scoped and identical to how Install-Git handles
# PortableGit. Same UX guarantee: works on locked-down enterprise
# machines with no admin rights.
Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..."
Write-Info "(no admin rights required; isolated from any system Node install)"
try {
$arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }
$indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/"
$indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
$zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value
if ($zipName) {
$downloadUrl = "${indexUrl}${zipName}"
$tmpZip = "$env:TEMP\$zipName"
$tmpDir = "$env:TEMP\hermes-node-extract"
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir }
Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force
$extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1
if ($extractedDir) {
if (Test-Path "$HermesHome\node") { Remove-Item -Recurse -Force "$HermesHome\node" }
Move-Item $extractedDir.FullName "$HermesHome\node"
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Session PATH so the rest of this run sees node/npm.
$env:Path = "$HermesHome\node;$env:Path"
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Persist to User PATH so fresh shells (and future stages
# in cross-process driver mode) see it. Matches the
# pattern Install-Git uses for PortableGit.
$nodeDir = "$HermesHome\node"
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
if ($userPathItems -notcontains $nodeDir) {
$userPathItems += $nodeDir
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
}
$version = & "$HermesHome\node\node.exe" --version
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)"
$script:HasNode = $true
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
return $true
}
}
} catch {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Portable Node.js download failed: $_"
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Fallback: try winget (used to be primary, demoted because the MSI
# install triggers a UAC prompt that frequently appears minimized in
# the taskbar -- looks like a hang to users on stock Windows).
# Kept for environments where the portable download fails (proxy,
# locked firewall, etc.) but the user is willing to consent to UAC.
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..."
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
try {
# Relax EAP=Stop so stderr lines from winget don't get wrapped
# as ErrorRecords and short-circuit the 2>&1 pipe before we can
# check the post-condition. See the long comment in Install-Uv
# for the same pattern.
$ErrorActionPreference = "Continue"
winget install OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Refresh PATH
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command node -ErrorAction SilentlyContinue) {
$version = node --version
Write-Success "Node.js $version installed via winget"
$script:HasNode = $true
return $true
}
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
}
}
Write-Info "Install manually: https://nodejs.org/en/download/"
$script:HasNode = $false
return $true
}
function Install-SystemPackages {
$script:HasRipgrep = $false
$script:HasFfmpeg = $false
$needRipgrep = $false
$needFfmpeg = $false
Write-Info "Checking ripgrep (fast file search)..."
if (Get-Command rg -ErrorAction SilentlyContinue) {
$version = rg --version | Select-Object -First 1
Write-Success "$version found"
$script:HasRipgrep = $true
} else {
$needRipgrep = $true
}
Write-Info "Checking ffmpeg (TTS voice messages)..."
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
Write-Success "ffmpeg found"
$script:HasFfmpeg = $true
} else {
$needFfmpeg = $true
}
if (-not $needRipgrep -and -not $needFfmpeg) { return }
# Build description and package lists for each package manager
$descParts = @()
$wingetPkgs = @()
$chocoPkgs = @()
$scoopPkgs = @()
if ($needRipgrep) {
$descParts += "ripgrep for faster file search"
$wingetPkgs += "BurntSushi.ripgrep.MSVC"
$chocoPkgs += "ripgrep"
$scoopPkgs += "ripgrep"
}
if ($needFfmpeg) {
$descParts += "ffmpeg for TTS voice messages"
$wingetPkgs += "Gyan.FFmpeg"
$chocoPkgs += "ffmpeg"
$scoopPkgs += "ffmpeg"
}
$description = $descParts -join " and "
$hasWinget = Get-Command winget -ErrorAction SilentlyContinue
$hasChoco = Get-Command choco -ErrorAction SilentlyContinue
$hasScoop = Get-Command scoop -ErrorAction SilentlyContinue
# Try winget first (most common on modern Windows)
if ($hasWinget) {
Write-Info "Installing $description via winget..."
foreach ($pkg in $wingetPkgs) {
try {
winget install $pkg --silent --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null
} catch { }
}
# Refresh PATH and recheck
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed"
$script:HasRipgrep = $true
$needRipgrep = $false
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed"
$script:HasFfmpeg = $true
$needFfmpeg = $false
}
if (-not $needRipgrep -and -not $needFfmpeg) { return }
}
# Fallback: choco
if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) {
Write-Info "Trying Chocolatey..."
foreach ($pkg in $chocoPkgs) {
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
}
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed via chocolatey"
$script:HasRipgrep = $true
$needRipgrep = $false
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed via chocolatey"
$script:HasFfmpeg = $true
$needFfmpeg = $false
}
}
# Fallback: scoop
if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) {
Write-Info "Trying Scoop..."
foreach ($pkg in $scoopPkgs) {
try { scoop install $pkg 2>&1 | Out-Null } catch { }
}
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed via scoop"
$script:HasRipgrep = $true
$needRipgrep = $false
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed via scoop"
$script:HasFfmpeg = $true
$needFfmpeg = $false
}
}
# Show manual instructions for anything still missing
if ($needRipgrep) {
Write-Warn "ripgrep not installed (file search will use findstr fallback)"
Write-Info " winget install BurntSushi.ripgrep.MSVC"
}
if ($needFfmpeg) {
Write-Warn "ffmpeg not installed (TTS voice messages will be limited)"
Write-Info " winget install Gyan.FFmpeg"
}
}
# ============================================================================
# Installation
# ============================================================================
function Install-Repository {
Write-Info "Installing to $InstallDir..."
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
$didUpdate = $false
if (Test-Path $InstallDir) {
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# Test-Path "$InstallDir\.git" returns True when .git is a file OR a
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# directory OR a symlink OR a submodule-style gitfile -- and also when
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# it's a broken stub left over from a failed previous install (e.g.
# a partial Remove-Item that couldn't delete a locked index.lock).
# Validate the repo properly by asking git itself. Two checks
# belt-and-braces: rev-parse AND git status. If either fails the
# repo is broken and we fall through to a fresh clone.
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
$repoValid = $false
if (Test-Path "$InstallDir\.git") {
Push-Location $InstallDir
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
try {
# Reset $LASTEXITCODE before the probe so we don't pick up
# a stale 0 from an earlier git call in this session.
$global:LASTEXITCODE = 0
$revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1
$revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true")
$global:LASTEXITCODE = 0
$null = & git -c windows.appendAtomically=false status --short 2>&1
$statusOk = ($LASTEXITCODE -eq 0)
if ($revParseOk -and $statusOk) {
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
$repoValid = $true
}
} catch {}
Pop-Location
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
}
if ($repoValid) {
Write-Info "Existing installation found, updating..."
Push-Location $InstallDir
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# Wrap the entire fetch+checkout block in EAP=Continue so git's
# routine stderr output (e.g. 'From <url>' info lines emitted by
# `git fetch`) doesn't terminate the script under the global
# EAP=Stop. We rely on $LASTEXITCODE for actual failures.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
try {
git -c windows.appendAtomically=false fetch origin
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# Precedence: Commit > Tag > Branch. Commit and Tag check
# out as detached HEAD intentionally -- they're meant to be
# reproducible pins, not branches the user pulls into.
if ($Commit) {
# Make sure we have the commit locally (a tag-less commit
# SHA isn't always reachable from any one branch fetch).
git -c windows.appendAtomically=false fetch origin $Commit
git -c windows.appendAtomically=false checkout --detach $Commit
if ($LASTEXITCODE -ne 0) { throw "git checkout $Commit failed (exit $LASTEXITCODE)" }
} elseif ($Tag) {
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
if ($LASTEXITCODE -ne 0) { throw "git checkout tag $Tag failed (exit $LASTEXITCODE)" }
} else {
git -c windows.appendAtomically=false checkout $Branch
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false pull origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
}
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
} finally {
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
$ErrorActionPreference = $prevEAP
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
Pop-Location
}
$didUpdate = $true
} else {
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# Directory exists but isn't a usable git repo. Wipe it and
# fall through to a fresh clone. A leftover ``.git`` stub from
# a partial uninstall used to lock the installer into the
# "update" branch forever, emitting three ``fatal: not a git
# repository`` errors and failing with "not in a git directory".
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Existing directory at $InstallDir is not a valid git repo -- replacing it."
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
try {
Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop
} catch {
Write-Err "Could not remove $InstallDir : $_"
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
Write-Info "terminals, running hermes processes) and try again."
throw
}
}
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
}
if (-not $didUpdate) {
$cloneSuccess = $false
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
# Git for Windows can fail on atomic file operations (hook templates,
# config lock files) due to antivirus, OneDrive, or NTFS filter drivers.
# The -c flag injects config before any file I/O occurs.
Write-Info "Configuring git for Windows compatibility..."
$env:GIT_CONFIG_COUNT = "1"
$env:GIT_CONFIG_KEY_0 = "windows.appendAtomically"
$env:GIT_CONFIG_VALUE_0 = "false"
git config --global windows.appendAtomically false 2>$null
# Try SSH first, then HTTPS, with -c flag for atomic write fix
Write-Info "Trying SSH clone..."
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
try {
git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlSsh $InstallDir
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
$env:GIT_SSH_COMMAND = $null
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
if (-not $cloneSuccess) {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Info "SSH failed, trying HTTPS..."
try {
git -c windows.appendAtomically=false clone --branch $Branch --recurse-submodules $RepoUrlHttps $InstallDir
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
}
# Fallback: download ZIP archive (bypasses git file I/O issues entirely)
if (-not $cloneSuccess) {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Git clone failed -- downloading ZIP archive instead..."
try {
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# Pick the ZIP URL for the most-specific ref the caller asked
# for. GitHub supports archive URLs for commits, tags, and
# branches; we honour Commit > Tag > Branch.
if ($Commit) {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/$Commit.zip"
$zipLabel = $Commit
} elseif ($Tag) {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/tags/$Tag.zip"
$zipLabel = $Tag
} else {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
$zipLabel = $Branch
}
$zipPath = "$env:TEMP\hermes-agent-$zipLabel.zip"
$extractPath = "$env:TEMP\hermes-agent-extract"
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# GitHub ZIPs extract to repo-branch/ subdirectory
$extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
if ($extractedDir) {
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
Move-Item $extractedDir.FullName $InstallDir -Force
Write-Success "Downloaded and extracted"
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# Initialize git repo so updates work later
Push-Location $InstallDir
git -c windows.appendAtomically=false init 2>$null
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
git remote add origin $RepoUrlHttps 2>$null
Pop-Location
Write-Success "Git repo initialized for future updates"
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
$cloneSuccess = $true
}
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# Cleanup temp files
Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
} catch {
Write-Err "ZIP download also failed: $_"
}
}
if (-not $cloneSuccess) {
throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
}
}
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
# Set per-repo config (harmless if it fails)
Push-Location $InstallDir
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
feat(install.ps1): strip BOM, add -Commit/-Tag pin params, harden git 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 4279da4db 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.
2026-05-18 15:45:28 -04:00
# Post-clone pin: when a clone (or ZIP-fallback init) just landed us on
# $Branch's tip, honour the higher-precedence $Commit / $Tag by checking
# the exact ref out as a detached HEAD. Skipped for the in-place update
# path (above) since that already routed via the same precedence.
if (-not $didUpdate) {
# Same EAP=Continue wrap as the update path -- git fetch's 'From <url>'
# info line goes to stderr and would terminate the script under the
# global EAP=Stop otherwise. We check $LASTEXITCODE for real errors.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
if ($Commit) {
Write-Info "Pinning to commit $Commit..."
git -c windows.appendAtomically=false fetch origin $Commit
git -c windows.appendAtomically=false checkout --detach $Commit
if ($LASTEXITCODE -ne 0) {
throw "git checkout $Commit failed (exit $LASTEXITCODE)"
}
} elseif ($Tag) {
Write-Info "Pinning to tag $Tag..."
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
if ($LASTEXITCODE -ne 0) {
throw "git checkout tag $Tag failed (exit $LASTEXITCODE)"
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
}
# Ensure submodules are initialized and updated
Write-Info "Initializing submodules..."
git -c windows.appendAtomically=false submodule update --init --recursive 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Warn "Submodule init failed (terminal/RL tools may need manual setup)"
} else {
Write-Success "Submodules ready"
}
Pop-Location
fix(install.ps1): validate existing repo via git itself + clean up broken stubs teknium1 hit "fatal: not in a git directory" on re-install when the previous install left a $InstallDir\.git stub that Test-Path matched but git didn't recognize (three "fatal: not a git repository" lines, then the script exited before touching anything). Two bugs: 1. Test-Path "$InstallDir\.git" was a weak gate — it matches .git whether it's a directory, file, symlink, submodule gitfile, OR a broken stub from a failed previous Remove-Item. Replaced with a real repo probe: Push-Location + git rev-parse --is-inside-work-tree + $LASTEXITCODE check. If git itself can't see a repo, we treat the directory as not-a-repo and fall through to fresh clone. 2. The original update path ignored $LASTEXITCODE. fetch/checkout/pull all emitted fatals but the script kept going. Now each command checks $LASTEXITCODE and throws with an explicit message. Also: when the directory exists but isn't a valid repo, the new code wipes it (Remove-Item -ErrorAction Stop) and falls through to fresh clone, instead of dying with the old "Directory exists but is not a git repository" error. If the wipe itself fails (file locked, hermes still running), we throw with a user-readable "close any programs using files in <dir>" hint. Refactored the function to use a $didUpdate flag instead of my earlier draft's early `return` — that was skipping the submodule init block at the bottom of the function. Both the update and fresh-clone paths now fall through to the submodule init step, which is correct (git pull doesn't auto-update submodules). PowerShell structural check: 21 functions defined, braces balanced.
2026-05-07 18:00:59 -07:00
Write-Success "Repository ready"
}
function Install-Venv {
if ($NoVenv) {
Write-Info "Skipping virtual environment (-NoVenv)"
return
}
Write-Info "Creating virtual environment with Python $PythonVersion..."
Push-Location $InstallDir
if (Test-Path "venv") {
Write-Info "Virtual environment already exists, recreating..."
Remove-Item -Recurse -Force "venv"
}
# uv creates the venv and pins the Python version in one step
& $UvCmd venv venv --python $PythonVersion
Pop-Location
Write-Success "Virtual environment ready (Python $PythonVersion)"
}
function Install-Dependencies {
Write-Info "Installing dependencies..."
Push-Location $InstallDir
if (-not $NoVenv) {
# Tell uv to install into our venv (no activation needed)
$env:VIRTUAL_ENV = "$InstallDir\venv"
}
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Hash-verified install (Tier 0) -- when uv.lock is present, prefer
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
# `uv sync --locked`. The lockfile records SHA256 hashes for every
# transitive dependency, so a compromised transitive (different hash
# than what we shipped) is REJECTED by the resolver. This is the
# *only* path that protects against the "direct dep is fine, but the
# dep's dep got worm-poisoned overnight" failure mode. The
# `uv pip install` tiers below re-resolve transitives fresh from PyPI
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# without any hash verification -- they exist to keep installs working
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
# when the lockfile is stale, missing, or out-of-sync with the
# current extras spec, NOT because they're equivalent in posture.
if (Test-Path "uv.lock") {
Write-Info "Trying tier: hash-verified (uv.lock) ..."
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# Critical flag choice: `--extra all`, NOT `--all-extras`.
# --all-extras = every [project.optional-dependencies] key,
# bypassing the curated [all] extra. On Windows
# that means [matrix] -> python-olm (no wheel,
# needs `make` to build from sdist) and the
# install fails.
# --extra all = just the [all] extra's contents (curated).
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
#
# UV_PROJECT_ENVIRONMENT pins the sync target to our venv\.
# Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync`
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# and creates a sibling .venv\ inside the repo -- leaving venv\
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
# empty and producing the broken state where `hermes.exe` exists
# in the wrong directory and imports fail with ModuleNotFoundError.
# (Mirrors the same flag in scripts/install.sh::install_deps.)
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
& $UvCmd sync --extra all --locked
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed (hash-verified via uv.lock)"
$script:InstalledTier = "hash-verified (uv.lock)"
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Skip the rest of the tiered cascade -- we already have a
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
# complete, hash-verified install.
$skipPipFallback = $true
} else {
Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
$skipPipFallback = $false
}
} else {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Info "uv.lock not found -- falling back to PyPI resolve (no hash verification)"
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
$skipPipFallback = $false
}
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# Install main package. Tiered fallback so a single flaky transitive
# doesn't silently drop everything. Each tier's stdout/stderr is
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# preserved -- no Out-Null swallowing -- so the user can see what failed.
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
#
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Tier 1: [all] -- the curated extra in pyproject.toml.
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# Tier 2: [all] minus the currently-broken extras list ($brokenExtras).
# Edit $brokenExtras below when something on PyPI breaks; this
# lets users keep the rest of [all] when one transitive is
# unavailable. The list of [all]'s contents is parsed from
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# pyproject.toml at runtime -- there is NO hand-mirrored copy
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# to drift out of sync.
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Tier 3: bare `.` -- last-resort so at least the core CLI launches.
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
# Currently-broken extras. Edit this list when an upstream package
# gets quarantined / yanked / breaks resolution. Empty means everything
# in [all] should be installable; populate with the names of extras
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# whose deps are temporarily unavailable.
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
$brokenExtras = @()
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
# Parse [project.optional-dependencies].all from pyproject.toml.
# tomllib is stdlib on Python 3.11+ which the bootstrap guarantees.
$pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
$allExtras = @()
if (Test-Path $pythonExeForParse) {
$parsed = & $pythonExeForParse -c @"
import re, sys, tomllib
try:
with open('pyproject.toml', 'rb') as fh:
data = tomllib.load(fh)
specs = data['project']['optional-dependencies']['all']
out = []
for s in specs:
m = re.search(r'hermes-agent\[([\w-]+)\]', s)
if m: out.append(m.group(1))
print(','.join(out))
except Exception:
sys.exit(1)
"@ 2>$null
if ($LASTEXITCODE -eq 0 -and $parsed) {
$allExtras = $parsed.Trim().Split(',')
}
}
if (-not $allExtras -or $allExtras.Count -eq 0) {
Write-Warn "Could not parse [all] from pyproject.toml; Tier 2 will be a no-op."
$safeAll = "all"
} else {
$safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join ","
}
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
$brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" }
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
$installTiers = @(
fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] (#24515) * fix(install): use `--extra all` not `--all-extras`; drop lazy-covered extras from [all] Two coupled fixes for the Windows install hang where uv sync built python-olm from sdist and failed on missing make. # Root cause: --all-extras vs --extra all (credit: ethernet) `uv sync --all-extras` installs every key in [project.optional- dependencies], bypassing the curated [all] extra entirely. So even when [all] excluded [matrix], [rl], [yc-bench], etc., the installer pulled them anyway because they were still defined as extras. On Windows that meant python-olm (no wheel, needs make to build from sdist) and the install died there. The right flag is `--extra all` — install just the [all] extra's contents, respecting curation. Empirically verified via dry-run: --all-extras: pulls python-olm, mautrix, ctranslate2, onnxruntime, atroposlib, tinker, wandb, modal, daytona, vercel, python-telegram-bot, discord.py, slack-bolt, dingtalk-stream, lark-oapi, anthropic, boto3, edge-tts, elevenlabs, exa-py, fal-client, faster- whisper, firecrawl-py, honcho-ai, parallel-web --extra all: pulls none of those — just [all]'s curated set Dockerfile already uses `--extra all` (with comment explaining the gotcha) — knowledge existed; the gap was install.sh / install.ps1 / setup-hermes.sh. Sites fixed: scripts/install.sh L1118, scripts/install.ps1 L809, setup-hermes.sh L245. # Companion fix: drop lazy-covered extras from [all] `tools/lazy_deps.py` already covers anthropic, bedrock, exa, firecrawl, parallel-web, fal, edge-tts, elevenlabs, modal, daytona, vercel, all messaging platforms (telegram/discord/slack/matrix/ dingtalk/feishu), honcho, and faster-whisper. They were ALSO in [all], which defeats the whole point of lazy-install — fresh installs eager-pulled them and inherited whatever was broken upstream (the matrix → python-olm → no Windows wheel chain being the proximate symptom). [all] now contains only what genuinely can't be lazy-installed: cron, cli, dev, pty, mcp, homeassistant, sms, acp, google, web, youtube. Same trim applied to [termux-all]. New regression test asserts the contract: every extra in LAZY_DEPS must NOT also appear in [all]. # Companion fix: surface uv progress + errors setup-hermes.sh's hash-verified path swallowed uv's stderr to a tempfile, identical to the install.sh bug fixed in PR #24504. Same fix applied: stream stderr through directly so users see live progress instead of staring at a frozen prompt. # Files - pyproject.toml: trim [all] and [termux-all] to non-lazy extras only. - scripts/install.sh: --all-extras → --extra all; trim _ALL_EXTRAS / _PYPI_EXTRAS to match. - scripts/install.ps1: --all-extras → --extra all; trim $allExtras / $pypiExtras to match. - setup-hermes.sh: --all-extras → --extra all; stream stderr. - tests/test_project_metadata.py: invert matrix-in-[all] assertion; add lazy-coverage contract test. - uv.lock: regenerated. # Validation 5/5 metadata tests pass. 37/37 in update_autostash + tool_token_ estimation. `uv lock --check` passes. Empirical dry-run confirms `--extra all` excludes python-olm + RL chain on the new lockfile. * fix(install): parse [all] from pyproject.toml instead of mirroring it ethernet's review point: the previous patch left two hand-mirrored copies of [all]'s contents (in install.sh's $_ALL_EXTRAS and install.ps1's $allExtras). That guarantees future drift the next time pyproject.toml's [all] changes. Now both scripts parse pyproject.toml at install time using stdlib tomllib (Python 3.11+, which the bootstrap step already requires). Single source of truth. The only purpose of the parsed list is to build the 'Tier 2: [all] minus broken extras' fallback spec — so we parse, filter against $brokenExtras, and rebuild the .[a,b,c] spec. Also: removed redundant fallback tiers. Before: Tier 1 [all] Tier 2 [all] minus broken Tier 3 PyPI-only extras (no git deps) Tier 4 [web,mcp,cron,cli,messaging,dev] Tier 5 . After: Tier 1 [all] Tier 2 [all] minus broken Tier 3 . Tier 3 (PyPI-only) and Tier 4 (dashboard+core) used to dodge the [rl] git+sdist deps and the [matrix] python-olm build. Both are no longer in [all] post-2026-05-12 lazy-install migration, so the carve-out tiers had no remaining content. Tier 4 also referenced [messaging], which is now lazy-installed — the hardcoded fallback was actually inconsistent with the new policy. Defensive fallback: if tomllib parse fails (corrupted pyproject, unexpected schema), Tier 2 collapses to '.[all]' (same as Tier 1) so the broken-extras path becomes a no-op rather than crashing. * fix(gateway): hide Matrix from setup picker on Windows Matrix is the one messaging platform that has no working install path on Windows: [matrix] -> mautrix[encryption] -> python-olm, which has Linux-only wheels and needs make + libolm to build from sdist. The [all] cleanup in this PR keeps mautrix out of fresh installs, but a user who picked Matrix in 'hermes setup gateway' would still walk into the same sdist build failure when the wizard tried to install the extra. Hide the option at the picker so users never get the chance to try. The gate lives in _all_platforms() — single source of truth for the setup wizard, the curses gateway-config menu, and any future picker. Adapter loading at runtime is intentionally NOT gated: users who already have MATRIX_* env vars set (e.g. config copied from a Linux install) keep working if they somehow have python-olm available. This is the lowest-friction fix — picker visibility only. Tests cover linux/darwin/win32 and verify other platforms aren't collateral damage.
2026-05-12 15:06:25 -07:00
@{ Name = "all"; Spec = ".[all]" },
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
@{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" },
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
@{ Name = "core only (no extras)"; Spec = "." }
)
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
$installed = $skipPipFallback
if (-not $skipPipFallback) {
foreach ($tier in $installTiers) {
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
Write-Info "Trying tier: $($tier.Name) ..."
& $UvCmd pip install -e $tier.Spec
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed ($($tier.Name))"
$script:InstalledTier = $tier.Name
$installed = $true
break
}
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback (#24220) * feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback Three coordinated mitigations for the Mini Shai-Hulud worm hitting mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package compromise that follows. # What this PR makes true 1. Users with the poisoned mistralai 2.4.6 in their venv get a loud detection banner with copy-pasteable remediation steps the moment they run hermes (and on every gateway startup). 2. One quarantined / yanked PyPI package can no longer silently demote a fresh install to 'core only' — the installer keeps every other extra and tells the user which tier landed. 3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can lazy-install on first use under a strict allowlist, instead of eagerly pulling everything at install time. # Detection: hermes_cli/security_advisories.py - ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for mistralai==2.4.6). Adding the next one is a single dataclass. - detect_compromised() uses importlib.metadata.version() — no pip dependency, works in uv venvs that lack pip. - Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits the startup banner to once per 24h per advisory. - Acks persisted to security.acked_advisories in config.yaml; never re-banner after ack. - Wired into: * hermes doctor — runs first, prints full remediation block * hermes doctor --ack <id> — dismisses an advisory * cli.py interactive run() and single-query branches — short stderr banner pointing at hermes doctor * gateway/run.py startup — operator-visible warning in gateway.log # Lazy-install framework: tools/lazy_deps.py - LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs, memory.honcho, provider.bedrock, etc.) to pip specs. - ensure(feature) installs missing deps in the active venv via the uv → pip → ensurepip ladder (matches tools_config._pip_install). - Strict spec safety regex rejects URLs, file paths, shell metas, pip flag injection, control chars — only PyPI-by-name accepted. - Gated on security.allow_lazy_installs (default true) plus the HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs. - Migrated three backends as proof of pattern: * tools/tts_tool.py — _import_elevenlabs() calls ensure first * plugins/memory/honcho/client.py — get_honcho_client lazy-installs * tts.mistral / stt.mistral entries pre-registered for when PyPI restores mistralai # Installer fallback tiers scripts/install.sh, scripts/install.ps1, setup-hermes.sh: - Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one array when a transitive breaks; users keep every other extra. - New 'all minus known-broken' tier between [all] and the existing PyPI-only-extras tier. Only kicks in when [all] fails resolve. - All three tiers explicit: every fallback announces which tier landed and prints a re-run hint when not on Tier 1. - install.ps1 and install.sh both regenerate their tier specs from the same _BROKEN_EXTRAS array so updates stay in sync. Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral' in its extra list — bug fixed by the refactor (mistral is filtered out). # Config hermes_cli/config.py — DEFAULT_CONFIG.security gains: - acked_advisories: [] (advisory IDs the user has dismissed) - allow_lazy_installs: True (security gate for ensure()) No config version bump needed — both keys nest under existing security: block, and load_config's deep-merge picks up DEFAULT_CONFIG defaults for users with older configs. # Tests tests/hermes_cli/test_security_advisories.py — 23 tests covering: - detect_compromised matches/non-matches, wildcard frozenset - ack persistence, idempotence, blank rejection, config-failure path - banner cache rate limiting + 24h re-banner + ack-stops-banner - short_banner_lines / full_remediation_text / render_doctor_section / gateway_log_message - shipped catalog well-formedness invariant tests/tools/test_lazy_deps.py — 40 tests covering: - spec safety: 11 safe parametrized + 18 unsafe parametrized - allowlist: unknown-feature rejection, namespace.name shape, every shipped spec passes the safety regex - security gating: config flag, env var, default, fail-open - ensure() happy/sad paths: already-satisfied, install success, pip stderr surfaced on failure, install-succeeds-but-still-missing - is_available, feature_install_command Combined: 63 new tests, all passing under scripts/run_tests.sh. # Validation - scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py tests/tools/test_lazy_deps.py → 63/63 passing - scripts/run_tests.sh tests/hermes_cli/test_doctor.py tests/hermes_cli/test_doctor_command_install.py tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing - scripts/run_tests.sh tests/hermes_cli/ tests/tools/ → 9191 passed, 8 pre-existing failures (verified on origin/main before this change) - bash -n on install.sh and setup-hermes.sh → OK - py_compile on all modified .py files → OK - End-to-end smoke test of detect_compromised + render_doctor_section + gateway_log_message with mocked installed version → produces copy-pasteable remediation output # Community Full advisory + remediation steps: website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md Short-form post drafts (Discord, GitHub pinned issue, README banner): scripts/community-announcement-shai-hulud.md Refs: PR #24205 (mistral disabled), Socket Security advisory <https://socket.dev/blog/mini-shai-hulud-worm-pypi> * build(deps): pin every direct dep to ==X.Y.Z (no ranges) Companion to the supply-chain advisory work: replace every >=/</~= range in pyproject.toml's [project.dependencies] and [project.optional-dependencies] with an exact ==X.Y.Z pin sourced from uv.lock. Why: ranges allow PyPI to ship a fresh version of any direct dep at any time without a code review on our side. With ranges, the malicious mistralai 2.4.6 release would have been pulled by every fresh 'pip install -e .[all]' for the hours between upload and PyPI's quarantine — exactly the install window we got hit on. Exact pins close that window: the only way a new package version reaches a user is via an intentional update on our end. What the user-facing change is: nothing, behavior-wise. Every package resolves to the same version it was already resolving to via uv.lock — the pins just remove the resolver's freedom to pick a different one. Cost: any user installing Hermes alongside another package that requires a newer pin gets a resolver conflict. Acceptable for our isolated-venv install path; documented in the new comment block. Build-system requires line (setuptools>=61.0) is intentionally left as a range — pinning the build backend would block fresh pip from bootstrapping the build on architectures where that exact wheel isn't available. mistral extra (mistralai==2.3.0) is pinned but stays out of [all] (per PR #24205). 'uv lock' regeneration will fail until PyPI restores mistralai; lockfile regeneration is gated behind that, NOT on every PR. LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy- install pathway can never resolve a different version than the one declared in pyproject.toml. Validation: - Cross-checked all 77 pinned direct deps in pyproject.toml against uv.lock — every pin matches the resolved version exactly. - Cross-checked all LAZY_DEPS specs against uv.lock — same. - 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly. - tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py → 63/63 passing (every shipped spec passes the safety regex). - Doctor + TTS + transcription targeted suite → 146/146 passing. * build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra You asked: 'what about the dependencies the dependencies rely on?' — correctly noting that exact-pinning direct deps in pyproject.toml does NOT cover the transitive graph. `pip install` and `uv pip install` both re-resolve transitives fresh from PyPI at install time, so a compromised transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would still hit our users even with every direct dep exact-pinned. # What this commit fixes 1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.** uv.lock records SHA256 hashes for every transitive — a compromised package with a different hash gets REJECTED. Falls through to the existing `uv pip install` cascade if the lockfile is missing or stale, with a loud warning that the fallback path does NOT hash-verify transitives. Previously only `setup-hermes.sh` (the dev path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1` (the paths fresh users actually run) skipped it. 2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI project is fully quarantined right now — every version returns 404, so any pin we wrote was unresolvable, which broke `uv lock --check` in CI. Restoration is documented in pyproject.toml as a 5-step checklist (verify, re-add extra, re-enable in 4 modules, regenerate lock, optionally re-add to [all]). 3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/ jsonpath-python pruned. `uv lock --check` now passes. # Defense-in-depth view | Layer | Where | Protects against | |----------------------------|-------------------|-------------------------------------------| | Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise | | uv.lock + `--locked` install | transitive graph | transitive worm injection | | Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs | | `uv lock --check` CI gate | every PR | drift between pyproject and lockfile | | `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit | The exact pinning + hash verification together close the supply-chain gap. Without the lockfile path, exact pins alone are theater. # Validation - `uv lock --check` → passes (262 packages resolved, no drift). - `bash -n` on install.sh + setup-hermes.sh → OK. - 209/209 tests passing across new + adjacent test files (test_lazy_deps.py, test_security_advisories.py, test_doctor.py, test_tts_mistral.py, test_transcription_tools.py). - TOML parse OK. * chore: remove community announcement drafts (PR body covers it) * build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard) Extends the lazy-install framework to cover everything that's not used by every hermes session. Base install drops from ~60 packages to 45. Moved out of core dependencies = []: - anthropic (only when provider=anthropic native, not via aggregators) - exa-py, firecrawl-py, parallel-web (search backends; only when picked) - fal-client (image gen; only when picked) - edge-tts (default TTS but still optional) New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web] [fal] [edge-tts]. All added to [all]. New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel}, tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix}, terminal.{modal,daytona,vercel}, tool.dashboard. Each import site now calls ensure() before importing the SDK. Where the module had a top-level try/except (telegram, discord, fastapi), the graceful-fallback pattern was extended to lazy-install on first check_*_requirements() call and re-bind module globals. Updated test_windows_native_support.py tzdata check from snapshot (>=2023.3 literal) to invariant (any version + win32 marker). Validation: - Base install: 45 packages (was ~60); 6 newly-extracted packages absent - uv lock --check: passes (262 packages, no drift) - 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing - py_compile clean on all 12 modified modules
2026-05-12 01:02:25 -07:00
}
}
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
if (-not $installed) {
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
}
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
# Baseline-import gate. Even if a tier reported success above, the
# actual deps may have landed somewhere other than $InstallDir\venv\
# (e.g. uv 0.5+ syncing into a sibling .venv\ when UV_PROJECT_ENVIRONMENT
# isn't set, leaving venv\ empty and hermes.exe broken with
# `ModuleNotFoundError: No module named 'dotenv'` on first run).
# We probe via the venv's own python so a misdirected sync is caught
# here, not 30 seconds later when the user runs `hermes`.
if (-not $NoVenv) {
$venvPython = "$InstallDir\venv\Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Relax EAP=Stop while running the import probe. Python writes
# deprecation warnings and import-system info to stderr; under
# EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and
# throws even when the imports succeed. $LASTEXITCODE is the
# reliable signal (it's 0 iff the python invocation exited 0,
# regardless of what was written to stderr).
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
& $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$importExitCode = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($importExitCode -ne 0) {
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
$sibling = "$InstallDir\.venv"
$hint = if (Test-Path $sibling) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
"Detected sibling .venv\ at $sibling -- uv synced there instead of venv\. Recover with: cd '$InstallDir'; Remove-Item -Recurse -Force venv; Move-Item .venv venv"
fix(install.ps1): pin uv sync to venv\, verify baseline imports on Windows (#25755) * fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already set in ~/.hermes/.env, `hermes model openrouter` / `hermes model ai-gateway` skipped the API-key prompt entirely and jumped straight to the model picker. Users with a broken / expired / wrong key had no way to replace it without editing ~/.hermes/.env by hand or re-running `hermes setup` from scratch. Both flows now route through the existing `_prompt_api_key()` helper, which surfaces [K]eep / [R]eplace / [C]lear when a key is already configured — the same UX the generic API-key providers (z.ai, MiniMax, Gemini, etc.) and the Daytona setup already use. * fix(install.ps1): pin uv sync target to venv\, verify baseline imports Two related Windows-installer bugs that produce a broken venv with `ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run. ## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\ `Install-Dependencies` creates the venv at `venv\` via `uv venv venv`, sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs `uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV` for the `sync` subcommand and uses the project default `.venv\` instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays empty except for the python.exe stub from the earlier `uv venv` call, `hermes.exe` ends up wired to the wrong site-packages. The bash installer (`scripts/install.sh`) already worked around this in `install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that flag tells uv exactly where to put the project env regardless of `VIRTUAL_ENV`. Port the same fix to PowerShell. ## Bug 2: no post-install verification If the sync still misdirects for any other reason (uv version drift, filesystem quirk, user re-run scenarios), the installer reports success and the user only finds out by running `hermes` and getting an unhelpful traceback. Add a baseline-import probe that runs the venv's own python against the four packages every `hermes` invocation needs (`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw with a recovery command tailored to whether a sibling `.venv\` exists. User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro steps were exactly this — `uv sync` landed in `.venv\`, recovered by junctioning `venv\` → `.venv\` to bridge the path mismatch.
2026-05-14 07:39:13 -07:00
} else {
"Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
}
throw "Baseline imports failed in $InstallDir\venv (dotenv/openai/rich/prompt_toolkit). The install completed but dependencies are not in the venv. $hint"
}
Write-Success "Baseline imports verified in venv"
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Verify the dashboard deps specifically -- they're the most common thing
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
# users hit and lazy-import errors from `hermes dashboard` are confusing.
# If tier 1 failed (the common case), [web] was still picked up by tiers
# 2-3; only tier 4 leaves you without it.
$pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
if (Test-Path $pythonExe) {
$webOk = $false
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Relax EAP=Stop while running the import probe; see the matching
# comment on the baseline-imports check above. Python writes
# deprecation warnings to stderr and we don't want those wrapped
# as ErrorRecords that silently force the "not importable" path
# even when fastapi/uvicorn are actually installed.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
& $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $webOk = $true }
} catch { }
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$ErrorActionPreference = $prevEAP
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
if (-not $webOk) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work."
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
Write-Info "Attempting targeted install of [web] extra as last resort..."
& $UvCmd pip install -e ".[web]"
if ($LASTEXITCODE -eq 0) {
Write-Success "[web] extra installed; `hermes dashboard` should now work."
} else {
Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`""
}
}
fix(windows installer): UTF-8 BOM, tiered extras, skip tinker-atropos by default install.ps1 had three related problems that compounded into `hermes dashboard` failing to boot on Windows with 'No module named fastapi': 1. UTF-8 BOM missing. Windows PowerShell 5.1 (the default on Windows 10/11, which is what `irm | iex` runs under) reads files without a BOM as cp1252. install.ps1 has em-dashes, arrows, check marks, etc. — PS 5.1 mangled them and the file failed to parse. Added UTF-8 BOM so PS 5.1, PS 7, and the in-memory `irm | iex` path all read the file identically. 2. `uv pip install -e .[all]` had a single-tier silent fallback to bare `.` on any failure, with `2>&1 | Out-Null` swallowing the error. Any transient extras install failure (network hiccup, wheel build issue, etc.) would drop every optional extra including [web], and the installer would still print 'Main package installed'. Replaced with a four-tier fallback (.[all] -> PyPI-only extras -> dashboard+core -> bare) that prints output at every step and a targeted [web] verify+repair at the end so `hermes dashboard` specifically is never silently broken. 3. tinker-atropos was installed unconditionally after the main install. tinker-atropos/pyproject.toml pulls atroposlib and tinker from git+https://github.com/... which can fail on locked-down networks, flaky DNS, or rate-limited github.com and would half-install the venv. install.sh already skipped it by default with a one-liner for users who actually do RL training — install.ps1 now matches that behavior. Parse-checked clean under Windows PowerShell 5.1.26100.8115 (5318 tokens, 0 parse errors).
2026-05-08 11:55:15 -07:00
}
Pop-Location
Write-Success "All dependencies installed"
}
function Set-PathVariable {
Write-Info "Setting up hermes command..."
if ($NoVenv) {
$hermesBin = "$InstallDir"
} else {
$hermesBin = "$InstallDir\venv\Scripts"
}
# Add the venv Scripts dir to user PATH so hermes is globally available
# On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($currentPath -notlike "*$hermesBin*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$hermesBin;$currentPath",
"User"
)
Write-Success "Added to user PATH: $hermesBin"
} else {
Write-Info "PATH already configured"
}
# Set HERMES_HOME so the Python code finds config/data in the right place.
# Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead
# of the Unix default ~/.hermes
$currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User")
if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) {
[Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User")
Write-Success "Set HERMES_HOME=$HermesHome"
}
$env:HERMES_HOME = $HermesHome
# Update current session
$env:Path = "$hermesBin;$env:Path"
Write-Success "hermes command ready"
}
function Copy-ConfigTemplates {
Write-Info "Setting up configuration files..."
# Create ~/.hermes directory structure
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks Major feature additions inspired by OpenClaw/ClawdBot integration analysis: Voice Message Transcription (STT): - Auto-transcribe voice/audio messages via OpenAI Whisper API - Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp - Inject transcript as text so all models can understand voice input - Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe) Telegram Sticker Understanding: - Describe static stickers via vision tool with JSON-backed cache - Cache keyed by file_unique_id avoids redundant API calls - Animated/video stickers get emoji-based fallback description Discord Rich UX: - Native slash commands (/ask, /reset, /status, /stop) via app_commands - Button-based exec approvals (Allow Once / Always Allow / Deny) - ExecApprovalView with user authorization and timeout handling Slack Integration: - Full SlackAdapter using slack-bolt with Socket Mode - DMs, channel messages (mention-gated), /hermes slash command - File attachment handling with bot-token-authenticated downloads DM Pairing System: - Code-based user authorization as alternative to static allowlists - 8-char codes from unambiguous alphabet, 1-hour expiry - Rate limiting, lockout after failed attempts, chmod 0600 on data - CLI: hermes pairing list/approve/revoke/clear-pending Event Hook System: - File-based hook discovery from ~/.hermes/hooks/ - HOOK.yaml + handler.py per hook, sync/async handler support - Events: gateway:startup, session:start/reset, agent:start/step/end - Wildcard matching (command:* catches all command events) Cross-Channel Messaging: - send_message agent tool for delivering to any connected platform - Enables cron job delivery and cross-platform notifications Human-Like Response Pacing: - Configurable delays between message chunks (off/natural/custom) - HERMES_HUMAN_DELAY_MODE env var with min/max ms settings Warm Injection Message Style: - Retrofitted image vision messages with friendly kawaii-consistent tone - All new injection messages (STT, stickers, errors) use warm style Also: updated config migration to prompt for optional keys interactively, bumped config version, updated README, AGENTS.md, .env.example, cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
# Create .env
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) {
$examplePath = "$InstallDir\.env.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $envPath
Write-Success "Created ~/.hermes/.env from template"
} else {
New-Item -ItemType File -Force -Path $envPath | Out-Null
Write-Success "Created ~/.hermes/.env"
}
} else {
Write-Info "~/.hermes/.env already exists, keeping it"
}
# Create config.yaml
$configPath = "$HermesHome\config.yaml"
if (-not (Test-Path $configPath)) {
$examplePath = "$InstallDir\cli-config.yaml.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $configPath
Write-Success "Created ~/.hermes/config.yaml from template"
}
} else {
Write-Info "~/.hermes/config.yaml already exists, keeping it"
}
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
# Create SOUL.md if it doesn't exist (global persona file).
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
# flags the BOM as an invisible unicode character and refuses to
# load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we
# don't control which PowerShell version the user has. Go direct
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# to .NET with an explicit UTF8Encoding($false) -- BOM-free on every
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
# PowerShell version.
$soulPath = "$HermesHome\SOUL.md"
if (-not (Test-Path $soulPath)) {
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
$soulContent = @"
# Hermes Agent Persona
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
<!--
This file defines the agent's personality and tone.
The agent will embody whatever you write here.
Edit this to customize how Hermes communicates with you.
Examples:
- "You are a warm, playful assistant who uses kaomoji occasionally."
- "You are a concise technical expert. No fluff, just facts."
- "You speak like a friendly coworker who happens to know everything."
This file is loaded fresh each message -- no restart needed.
Delete the contents (or this file) to use the default personality.
-->
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
}
Write-Success "Configuration directory ready: ~/.hermes/"
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (Test-Path $pythonExe) {
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
Write-Success "Skills synced to ~/.hermes/skills/"
} catch {
# Fallback: simple directory copy
$bundledSkills = "$InstallDir\skills"
$userSkills = "$HermesHome\skills"
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Skills copied to ~/.hermes/skills/"
}
}
}
}
function Install-NodeDeps {
if (-not $HasNode) {
Write-Info "Skipping Node.js dependencies (Node not installed)"
return
}
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
fix(windows): prefer npm.cmd over npm.ps1, skip .py argv0 in relaunch Two fixes from teknium1's next install run: 1. **npm install: "npm.ps1 cannot be loaded because running scripts is disabled on this system."** Get-Command's default PATHEXT ordering picked up ``npm.ps1`` (the PowerShell shim) ahead of ``npm.cmd`` (the batch shim). Most Windows users have PowerShell's execution policy set to Restricted or RemoteSigned, which blocks unsigned ``.ps1`` files. ``npm.cmd`` has no such restriction and works universally. Install-NodeDeps now detects when Get-Command returned npm.ps1, looks for a sibling npm.cmd in the same directory, and prefers it. Prints an info line so the user sees why. Emits a warning + hint if only npm.ps1 is available. 2. **"Launch hermes chat now? Y" crashes with "%1 is not a valid Win32 application" on Windows installs.** The setup wizard calls ``relaunch(["chat"])``; ``resolve_hermes_bin()`` returned ``sys.argv[0]`` which was ``...\\hermes_cli\\main.py`` (because hermes was launched via ``python -m hermes_cli.main`` during setup). On Windows, ``os.access(script.py, os.X_OK)`` returns True because PATHEXT lists ``.py`` when the Python launcher is registered — but ``subprocess.run([script.py, ...])`` can't actually execute a ``.py`` directly. CreateProcessW needs a real PE file. Fixed ``resolve_hermes_bin`` to reject ``.py``/``.pyc`` argv0 values on Windows specifically. Falls through to ``shutil.which("hermes")`` (hermes.exe in the venv Scripts dir) or, as a final fallback, lets build_relaunch_argv build ``[sys.executable, "-m", "hermes_cli.main"]`` which is bulletproof. POSIX behaviour unchanged — ``.py`` argv0 with a shebang + chmod+x is still a valid exec target there. 3 new tests cover the Windows paths: .py argv0 + hermes.exe on PATH → returns hermes.exe; .py argv0 + no PATH → returns None (caller uses python -m); POSIX + executable .py → still accepted. 26 relaunch tests pass, no POSIX regressions.
2026-05-07 18:29:17 -07:00
# Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows
# ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim).
# Get-Command's default ordering picks whichever comes first in PATHEXT,
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# and on many systems that's .ps1 -- but .ps1 requires scripts to be
fix(windows): prefer npm.cmd over npm.ps1, skip .py argv0 in relaunch Two fixes from teknium1's next install run: 1. **npm install: "npm.ps1 cannot be loaded because running scripts is disabled on this system."** Get-Command's default PATHEXT ordering picked up ``npm.ps1`` (the PowerShell shim) ahead of ``npm.cmd`` (the batch shim). Most Windows users have PowerShell's execution policy set to Restricted or RemoteSigned, which blocks unsigned ``.ps1`` files. ``npm.cmd`` has no such restriction and works universally. Install-NodeDeps now detects when Get-Command returned npm.ps1, looks for a sibling npm.cmd in the same directory, and prefers it. Prints an info line so the user sees why. Emits a warning + hint if only npm.ps1 is available. 2. **"Launch hermes chat now? Y" crashes with "%1 is not a valid Win32 application" on Windows installs.** The setup wizard calls ``relaunch(["chat"])``; ``resolve_hermes_bin()`` returned ``sys.argv[0]`` which was ``...\\hermes_cli\\main.py`` (because hermes was launched via ``python -m hermes_cli.main`` during setup). On Windows, ``os.access(script.py, os.X_OK)`` returns True because PATHEXT lists ``.py`` when the Python launcher is registered — but ``subprocess.run([script.py, ...])`` can't actually execute a ``.py`` directly. CreateProcessW needs a real PE file. Fixed ``resolve_hermes_bin`` to reject ``.py``/``.pyc`` argv0 values on Windows specifically. Falls through to ``shutil.which("hermes")`` (hermes.exe in the venv Scripts dir) or, as a final fallback, lets build_relaunch_argv build ``[sys.executable, "-m", "hermes_cli.main"]`` which is bulletproof. POSIX behaviour unchanged — ``.py`` argv0 with a shebang + chmod+x is still a valid exec target there. 3 new tests cover the Windows paths: .py argv0 + hermes.exe on PATH → returns hermes.exe; .py argv0 + no PATH → returns None (caller uses python -m); POSIX + executable .py → still accepted. 26 relaunch tests pass, no POSIX regressions.
2026-05-07 18:29:17 -07:00
# enabled in PowerShell's execution policy, which most Windows users
# don't have (the Restricted / RemoteSigned default blocks unsigned
# .ps1 files). .cmd has no such restriction and works on every box.
#
# Strategy: look next to the npm shim we found and prefer npm.cmd if
# it exists in the same directory. Fall back to whatever Get-Command
# returned if we can't find a .cmd sibling.
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "npm not found on PATH -- skipping Node.js dependencies."
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later."
return
}
$npmExe = $npmCmd.Source
fix(windows): prefer npm.cmd over npm.ps1, skip .py argv0 in relaunch Two fixes from teknium1's next install run: 1. **npm install: "npm.ps1 cannot be loaded because running scripts is disabled on this system."** Get-Command's default PATHEXT ordering picked up ``npm.ps1`` (the PowerShell shim) ahead of ``npm.cmd`` (the batch shim). Most Windows users have PowerShell's execution policy set to Restricted or RemoteSigned, which blocks unsigned ``.ps1`` files. ``npm.cmd`` has no such restriction and works universally. Install-NodeDeps now detects when Get-Command returned npm.ps1, looks for a sibling npm.cmd in the same directory, and prefers it. Prints an info line so the user sees why. Emits a warning + hint if only npm.ps1 is available. 2. **"Launch hermes chat now? Y" crashes with "%1 is not a valid Win32 application" on Windows installs.** The setup wizard calls ``relaunch(["chat"])``; ``resolve_hermes_bin()`` returned ``sys.argv[0]`` which was ``...\\hermes_cli\\main.py`` (because hermes was launched via ``python -m hermes_cli.main`` during setup). On Windows, ``os.access(script.py, os.X_OK)`` returns True because PATHEXT lists ``.py`` when the Python launcher is registered — but ``subprocess.run([script.py, ...])`` can't actually execute a ``.py`` directly. CreateProcessW needs a real PE file. Fixed ``resolve_hermes_bin`` to reject ``.py``/``.pyc`` argv0 values on Windows specifically. Falls through to ``shutil.which("hermes")`` (hermes.exe in the venv Scripts dir) or, as a final fallback, lets build_relaunch_argv build ``[sys.executable, "-m", "hermes_cli.main"]`` which is bulletproof. POSIX behaviour unchanged — ``.py`` argv0 with a shebang + chmod+x is still a valid exec target there. 3 new tests cover the Windows paths: .py argv0 + hermes.exe on PATH → returns hermes.exe; .py argv0 + no PATH → returns None (caller uses python -m); POSIX + executable .py → still accepted. 26 relaunch tests pass, no POSIX regressions.
2026-05-07 18:29:17 -07:00
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) {
Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)"
$npmExe = $npmCmdSibling
} else {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Only npm.ps1 available -- install may fail if script execution is disabled."
fix(windows): prefer npm.cmd over npm.ps1, skip .py argv0 in relaunch Two fixes from teknium1's next install run: 1. **npm install: "npm.ps1 cannot be loaded because running scripts is disabled on this system."** Get-Command's default PATHEXT ordering picked up ``npm.ps1`` (the PowerShell shim) ahead of ``npm.cmd`` (the batch shim). Most Windows users have PowerShell's execution policy set to Restricted or RemoteSigned, which blocks unsigned ``.ps1`` files. ``npm.cmd`` has no such restriction and works universally. Install-NodeDeps now detects when Get-Command returned npm.ps1, looks for a sibling npm.cmd in the same directory, and prefers it. Prints an info line so the user sees why. Emits a warning + hint if only npm.ps1 is available. 2. **"Launch hermes chat now? Y" crashes with "%1 is not a valid Win32 application" on Windows installs.** The setup wizard calls ``relaunch(["chat"])``; ``resolve_hermes_bin()`` returned ``sys.argv[0]`` which was ``...\\hermes_cli\\main.py`` (because hermes was launched via ``python -m hermes_cli.main`` during setup). On Windows, ``os.access(script.py, os.X_OK)`` returns True because PATHEXT lists ``.py`` when the Python launcher is registered — but ``subprocess.run([script.py, ...])`` can't actually execute a ``.py`` directly. CreateProcessW needs a real PE file. Fixed ``resolve_hermes_bin`` to reject ``.py``/``.pyc`` argv0 values on Windows specifically. Falls through to ``shutil.which("hermes")`` (hermes.exe in the venv Scripts dir) or, as a final fallback, lets build_relaunch_argv build ``[sys.executable, "-m", "hermes_cli.main"]`` which is bulletproof. POSIX behaviour unchanged — ``.py`` argv0 with a shebang + chmod+x is still a valid exec target there. 3 new tests cover the Windows paths: .py argv0 + hermes.exe on PATH → returns hermes.exe; .py argv0 + no PATH → returns None (caller uses python -m); POSIX + executable .py → still accepted. 26 relaunch tests pass, no POSIX regressions.
2026-05-07 18:29:17 -07:00
Write-Info " If it fails, either enable PS script execution or install Node via winget."
}
}
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
# Helper: run "npm install" in a given directory and surface the real
# error when it fails. Returns $true on success.
#
# Implementation note: ``Start-Process -FilePath npm.cmd`` fails with
# ``%1 is not a valid Win32 application`` on some PowerShell versions
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
# PE file. The invocation-operator ``& $npmExe`` routes through the
# PowerShell command pipeline which DOES honour .cmd batch shims, so
# it works uniformly for npm.cmd, npx.cmd, and bare .exe files.
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
Push-Location $installDir
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
try {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Stream npm's output to BOTH the console and the log file via
# Tee-Object. Previously this called ``& npm install --silent
# *> $logPath`` which redirected every stream to disk and left
# the user staring at a frozen "Installing..." line for the
# duration of the install. On a fresh VM that's 1-3 minutes
# of total silence, indistinguishable from a hang.
#
# Tee writes the live output to stdout AND $logPath; we still
# capture the exit code afterwards and surface diagnostics
# on failure. Note: 2>&1 merges npm's stderr into the success
# stream first because Tee-Object only sees the success
# stream of the pipeline. ForEach-Object { "$_" } coerces
# each item to a string so PowerShell's NativeCommandError
# formatter doesn't wrap stderr lines as alarming red blocks
# (cosmetic polish; the underlying text is unchanged).
#
# Relax EAP around the npm invocation: with EAP=Stop (set at
# the top of this script), PowerShell wraps stderr lines from
# native commands captured via 2>&1 as ErrorRecord objects and
# throws on the first one -- even though npm exited 0. This
# is the same issue Test-Python and Install-Uv work around
# for uv's stderr-emitting installer. Check success via
# $LASTEXITCODE, which is reliable regardless of stderr noise.
$ErrorActionPreference = "Continue"
& $npmPath install --silent 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $logPath
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
$code = $LASTEXITCODE
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$ErrorActionPreference = $prevEAP
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
if ($code -eq 0) {
Write-Success "$label dependencies installed"
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
return $true
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "$label npm install failed -- exit code $code"
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
if (Test-Path $logPath) {
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
if ($errText) {
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
Write-Info " npm output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
}
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
Write-Info " Full log: $logPath"
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
}
}
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
Write-Info "Run manually later: cd `"$installDir`"; npm install"
return $false
} catch {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
Write-Warn "$label npm install could not be launched: $_"
return $false
} finally {
Pop-Location
}
}
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
# Browser tools
if (Test-Path "$InstallDir\package.json") {
Write-Info "Installing Node.js dependencies (browser tools)..."
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
$browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe
# Install Playwright Chromium (mirrors scripts/install.sh behaviour for
# Linux). Without this, tools/browser_tool.py::check_browser_requirements
# returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the
# browser_* tools are silently filtered out of the agent's tool schema.
# System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# agent-browser -- it expects a Playwright-managed Chromium.
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
if ($browserNpmOk) {
Write-Info "Installing browser engine (Playwright Chromium)..."
# npx lives next to npm in the same bin dir. Prefer .cmd to dodge
# the same execution-policy gotcha that affects npm.ps1 (see above).
$npmDir = Split-Path $npmExe -Parent
$npxExe = $null
foreach ($cand in @("npx.cmd", "npx.exe", "npx")) {
$try = Join-Path $npmDir $cand
if (Test-Path $try) { $npxExe = $try; break }
}
if (-not $npxExe) {
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
if ($npxCmd) { $npxExe = $npxCmd.Source }
}
if (-not $npxExe) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "npx not found -- cannot install Playwright Chromium."
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} else {
$pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log"
Push-Location $InstallDir
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Capture EAP outside the try block so the catch's restore call
# always has a meaningful value (see Install-Uv for the full
# rationale).
$prevEAP = $ErrorActionPreference
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
try {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Playwright Chromium is ~170MB compressed and the
# download regularly takes 3-10 minutes on a fresh
# VM. Tee the output to console + log so the user
# sees download progress in real time instead of
# staring at a silent prompt that looks hung. See
# _Run-NpmInstall above for the same pattern and
# the rationale behind 2>&1 before the pipe.
Write-Info "(this can take several minutes -- streaming progress below)"
# --yes auto-accepts npx's "Need to install playwright@X.Y.Z"
# confirmation prompt. Without it, npx 7+ blocks on stdin
# waiting for a y/N answer that never comes when this is
# invoked through a pipeline (Tee-Object disconnects stdin
# from the user's TTY), and the install hangs indefinitely
# after printing "Need to install the following packages:
# playwright@X.Y.Z".
#
# Relax EAP around the playwright invocation: playwright
# emits a "Chromium downloaded to ..." success banner to
# stderr after a successful install. Under EAP=Stop, the
# 2>&1 merge wraps those stderr lines as ErrorRecord
# objects and throws -- causing this catch block to fire
# with a mangled banner as the error message even though
# the install actually succeeded. Check $LASTEXITCODE
# instead, which is the reliable signal.
#
# The ForEach-Object { "$_" } coercion BEFORE Tee-Object
# is a cosmetic polish: with bare 2>&1, PowerShell still
# renders stderr lines through its NativeCommandError
# formatter (the red "npx.cmd : ..." block). Coercing
# each pipeline item to a string strips that wrapper so
# the user sees clean playwright output instead of the
# alarming-looking error formatting.
$ErrorActionPreference = "Continue"
& $npxExe --yes playwright install chromium 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $pwLog
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
$pwCode = $LASTEXITCODE
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$ErrorActionPreference = $prevEAP
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
if ($pwCode -eq 0) {
Write-Success "Playwright Chromium installed (browser tools ready)"
Remove-Item -Force $pwLog -ErrorAction SilentlyContinue
} else {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "Playwright Chromium install failed -- exit code $pwCode"
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
Write-Warn "Browser tools will not work until Chromium is installed."
if (Test-Path $pwLog) {
$pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue
if ($pwErr) {
$snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr }
Write-Info " playwright output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
}
Write-Info " Full log: $pwLog"
}
}
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
}
} catch {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
fix(windows): auto-install Playwright Chromium + surface it in doctor scripts/install.sh runs 'npx playwright install --with-deps chromium' on every Linux distro after the npm-install step, which is why browser tools Just Work on Linux. scripts/install.ps1 never did the equivalent step, so on native Windows installs check_browser_requirements() in tools/browser_tool.py would return False (no Chromium under %LOCALAPPDATA%\ms-playwright) and every browser_* tool got silently filtered out of the agent's tool schema — no error, no log entry, user just wondered why the tools didn't exist. Two-part fix: 1. scripts/install.ps1: after 'npm install' in InstallDir succeeds, run 'npx playwright install chromium'. Resolves npx via the same execution-policy-aware logic already used for npm (prefer npx.cmd next to npmExe, fall back to Get-Command). Surfaces a warning + manual-recovery hint when the install fails, matching install.sh behaviour for distros. 2. hermes_cli/doctor.py: after the agent-browser check, lazily import tools.browser_tool and reuse the exact same _chromium_installed() predicate check_browser_requirements() uses, so the doctor signal cannot drift from the runtime gate. Skip the check when Camofox / CDP override / a cloud provider / Lightpanda is configured (those bypass local Chromium). On missing Chromium, the hint is platform-correct: '--with-deps' on POSIX, plain 'install chromium' on win32. Verified on Windows 10: - 'npx playwright install chromium' completes successfully, drops Chrome Headless Shell under %LOCALAPPDATA%\ms-playwright - check_browser_requirements() flips from False -> True - 'hermes doctor' now prints either '✓ Playwright Chromium (browser engine)' or '⚠ Playwright Chromium not installed' + fix command - tests/hermes_cli/test_doctor.py: 38/38 pass - tests/tools/test_browser_chromium_check.py: 16/16 pass
2026-05-08 07:56:35 -07:00
Write-Warn "Playwright Chromium install could not be launched: $_"
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} finally {
Pop-Location
}
}
}
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
}
# TUI
2026-04-08 09:46:40 -05:00
$tuiDir = "$InstallDir\ui-tui"
if (Test-Path "$tuiDir\package.json") {
Write-Info "Installing TUI dependencies..."
fix(windows): use PortableGit (not MinGit), fix relaunch os.execvp crash, surface npm errors Three real bugs from teknium1's first Windows install run: 1. **MinGit has no bash.exe.** MinGit is the minimal-automation Git for Windows distribution — it ships git.exe but deliberately strips bash and the POSIX coreutils. Installer logged "Could not locate bash.exe" and Hermes would fail to run any shell command. Switched to PortableGit — the full Git for Windows minus the installer UI. PortableGit ships bash.exe at <root>\bin\bash.exe plus sh, awk, sed, grep, curl, ssh in usr\bin\. ARM64 variant is detected separately (PortableGit-*-arm64.7z.exe). 32-bit falls back to MinGit-32-bit with a warning (PortableGit is 64-bit only). PortableGit ships as a 7z self-extractor (56MB vs MinGit's 38MB). We invoke it with `-o<target> -y` to extract silently — no 7z install needed, it's self-contained. Updated tools/environments/local.py::_find_bash candidate order to prefer the PortableGit layout (<root>\bin\bash.exe) with the MinGit layout (<root>\usr\bin\bash.exe) as a fallback so existing installs keep working. 2. **os.execvp "Exec format error" on Windows.** Setup wizard's "Launch hermes chat now? Y" called `os.execvp(["hermes", "chat"])` which on Windows can only swap to real Win32 .exe files — chokes with OSError(8) on .cmd batch shims and Python console-script wrappers. Added a win32 branch in hermes_cli/relaunch.py::relaunch() that uses subprocess.run + sys.exit — functionally identical (user sees "hermes exited, then new hermes started") with one extra PID in play. POSIX path is UNCHANGED — still uses os.execvp for in-place replacement. Catches OSError in the Windows branch and surfaces a "open a new terminal so PATH picks up, then re-run hermes" hint instead of a cryptic traceback. 3. **npm install failures silent on Windows.** The install.ps1 was invoking `npm install --silent 2>&1 | Out-Null` inside a try/catch. PowerShell's try/catch does NOT trigger on non-zero process exit codes — only on unhandled .NET exceptions — so npm failing printed a generic "npm install failed" with zero information about WHY. The silent pipe ate the stderr. Rewrote Install-NodeDeps to: - Resolve npm.cmd via Get-Command (respects PATHEXT) instead of relying on bare `npm` name resolution. - Use Start-Process with -PassThru to capture the actual exit code. - Redirect stderr to a temp log and surface the first ~800 chars of the real npm error when install fails, plus the log path for the full text. - Fail loudly with the right exit code instead of a misleading success. - Bail cleanly with a helpful message when npm isn't on PATH at all. 4. **"True" printing to console after Node check.** `Test-Node` returns $true; installer called it as a bare statement (no assignment, no cast). PowerShell prints bare return values. Wrapped the call in `[void](Test-Node)`. ## Tests - Added 3 new tests in tests/hermes_cli/test_relaunch.py covering the Windows branch: subprocess is called (not execvp), child exit code propagates, OSError surfaces a helpful message. All 23 tests pass (20 existing + 3 new). - 77 Windows-compat tests still pass, POSIX behaviour unchanged.
2026-05-07 17:42:47 -07:00
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
fix(windows): %1 install error, patch CRLF false-negative, SOUL.md BOM Three bugs from teknium1's successful install + diagnostic chat on Windows: 1. **Start-Process -FilePath npm.cmd fails with "%1 is not a valid Win32 application".** Start-Process bypasses cmd.exe and PATHEXT to call CreateProcessW directly, which refuses .cmd batch shims. Switched Install-NodeDeps to use PowerShell's invocation operator (``& $npmExe install --silent *> $log``) which DOES honour PATHEXT. Extracted a ``_Run-NpmInstall`` helper so the browser + TUI paths share the same logic. Captures $LASTEXITCODE correctly, still surfaces the real stderr on failure with a log-file pointer for the full output. 2. **patch tool returns false-negative on Windows due to CRLF round-trip.** Root cause was upstream of patch: ``subprocess.Popen(..., text=True, stdin=PIPE)`` on Windows translates ``\\n`` → ``\\r\\n`` when data flows through the stdin pipe. ``_pipe_stdin()`` was writing the patch's new_content string through a text-mode pipe, bash then wrote those CRLF bytes to disk, and patch's post-write verify compared the on-disk CRLF bytes against the original LF-only string — fail. Fixed in two places for defense in depth: - ``_pipe_stdin()`` now writes through ``proc.stdin.buffer`` with explicit UTF-8 encoding, bypassing Python's newline translation on every platform. No behaviour change on POSIX (bytes are identical) but stops the CRLF injection on Windows. - ``patch_replace``'s post-write verify normalizes CRLF→LF on both sides before comparing, so even if some future backend still translates newlines the patch tool won't report a bogus failure. 3. **SOUL.md gets a UTF-8 BOM on Windows PowerShell 5.1.** ``Set-Content -Encoding UTF8`` on PS5.1 writes UTF-8 WITH a byte-order-mark (changed in PS7 via ``utf8NoBOM``). Hermes's prompt-injection scanner sees the BOM (U+FEFF invisible char) and refuses to load the file, so SOUL.md's persona instructions never get applied. Fixed by writing the file via ``[System.IO.File]::WriteAllText`` with an explicit ``UTF8Encoding($false)`` — BOM-free on every PowerShell version. All POSIX behaviour verified unchanged: 198 tests pass across test_file_operations, test_local_env_cwd_recovery, test_code_execution, test_windows_native_support, test_windows_compat.
2026-05-07 18:11:43 -07:00
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
2026-04-08 09:46:40 -05:00
}
}
fix(windows): gateway status dedup + install.ps1 platform-SDK bootstrap ## Two residual Windows fixes that were hanging from earlier commits. ### 1. `hermes gateway status` reported 2 PIDs per gateway — TWO bugs compounded Diagnosed with psutil parent/child walk against live gateway PIDs: **Bug A (the real one): `_get_parent_pid` silently failed on Windows.** The helper shelled out to `ps -o ppid= -p <pid>`, which doesn't exist on Windows — `FileNotFoundError` → returns `None` → the ancestor walk terminated at `os.getpid()` alone. Consequence: the PID table scan in `_scan_gateway_pids` couldn't filter out `hermes gateway status`'s own launcher stub (a venv `pythonw.exe`/`python.exe` that matches the same `-m hermes_cli.main gateway` pattern as the gateway). Every status call saw "itself" as a second gateway. Fix: `_get_parent_pid` now calls `psutil.Process(pid).ppid()` first (psutil is a core dependency since 3dfb35700) and falls back to `ps` only when `shutil.which("ps")` succeeds — matching the Windows-footgun checker's "always guard `ps` / `wmic` / etc. with `shutil.which`" rule. Before: `Gateway process running (PID: 21952, 46880)` — 46880 changing on every call (the status invocation's own launcher, which died by the time the next status call looked). After (5 consecutive calls): ``` ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ``` Ancestor walk on the fix: 14 PIDs (full chain through bash/explorer) instead of the broken 1-PID set. **Bug B (the cosmetic one): venv-launcher dedup.** Standard Windows CPython venv behaviour is that `<venv>/Scripts/pythonw.exe` is a ~5 MB launcher stub that spawns the base Python (`C:\\Program Files\\Python311 \\pythonw.exe`) with the same command line and waits. Our process scanner sees two PIDs for every gateway: launcher + interpreter, same cmdline. Bug A masked this by accidentally counting the status call AS one of them; with Bug A fixed, we see both the real launcher and real interpreter for the gateway process itself. Fix: `_filter_venv_launcher_stubs` at the tail of `_scan_gateway_pids` walks each matched PID's ppid via psutil. Any PID that's the PARENT of another matched PID is a launcher stub — drop it, keep the child. Scoped to Windows (`is_windows() and len(pids) > 1`) and no-ops when psutil isn't importable. Net effect: `gateway status` now reports one PID per gateway — the interpreter — matching POSIX behaviour and user expectations. ### 2. `install.ps1`: bootstrap pip + auto-install platform SDKs New `Install-PlatformSdks` function wired between `Invoke-SetupWizard` and `Start-GatewayIfConfigured`. Fixes two related issues on fresh Windows installs: 1. The tiered `uv pip install` cascade (introduced in 87fca8342) correctly falls through when tier 1 `.[all]` fails on the RL git deps, but the fallback tiers can silently skip SDKs from `[messaging]` when there's a partial-resolve. Result: user sets `DISCORD_BOT_TOKEN` in `.env`, fires up gateway, hits "discord module not installed". 2. `uv` creates venvs WITHOUT pip by default, so the user's escape hatch (`pip install discord.py` in the venv) doesn't exist either. The new function: - Skips if `-NoVenv` (nothing to bootstrap into). - Scans `~/.hermes/.env` for messaging tokens (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, WHATSAPP_ENABLED), filtering placeholder values. - For each token that's set, runs `python -c "import <sdk>"` to verify. - If any import fails: runs `python -m ensurepip --upgrade` to bootstrap pip into the venv (idempotent — no-ops if pip is already present), then `pip install <spec>` for each missing SDK with specs mirroring pyproject.toml's `[messaging]` extra to avoid version drift. The `$ErrorActionPreference = "SilentlyContinue"` spans are not cosmetic — PowerShell wraps native-stderr from a non-zero-exit subprocess as a `NativeCommandError` that prints even through `*> $null` / `2>$null`. Save + restore EAP over the import-probe and pip-install blocks keeps the output clean. Verified on this Windows 10 box: - Initial state: telegram+fastapi+psutil present, discord+slack_sdk missing (tier 1 `.[all]` had failed — `.tirith-install-failed` marker in `%LOCALAPPDATA%\\hermes`). - First run with discord+slack tokens in .env: detects both missing, ensurepip (skipped — pip was already bootstrapped earlier this session for telegram), installs `discord.py[voice]==2.7.1` + `PyNaCl` + `davey`, installs `slack-sdk==3.41.0`. All imports succeed on verify. - Second run: all three SDKs report OK, function no-ops. Pip spec strings mirror pyproject.toml's `[messaging]` extra verbatim so a bump to the extra picks up here automatically — no drift. ### Files - `hermes_cli/gateway.py`: `_get_parent_pid` rewritten (psutil-first); `_filter_venv_launcher_stubs` added; `_scan_gateway_pids` dedups launchers on Windows when it finds >1 match. - `scripts/install.ps1`: new `Install-PlatformSdks` function (~85 lines); wired into the main flow at line 1438. ### Verification - `venv/Scripts/python.exe scripts/check-windows-footguns.py --all` → `✓ No Windows footguns found (380 file(s) scanned).` - `ast.parse` passes on gateway.py. - `[System.Management.Automation.Language.Parser]::ParseFile` passes on install.ps1. - Live gateway (PID 21952, running since 12:33 today) survived 5x stress loop of `hermes gateway status` without dying.
2026-05-08 13:10:34 -07:00
function Install-PlatformSdks {
# Ensure messaging-platform SDKs matching tokens the user added to
# ~/.hermes/.env are importable. Two problems this solves:
#
# 1. The tiered `uv pip install` cascade above can fall through to a
# lower tier when the first fails (common when RL git deps choke),
# which silently skips some messaging SDKs from [messaging].
# 2. `uv` creates the venv without pip. If a messaging SDK ends up
# missing, the user can't `pip install python-telegram-bot` to
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# recover -- pip simply isn't in their venv.
fix(windows): gateway status dedup + install.ps1 platform-SDK bootstrap ## Two residual Windows fixes that were hanging from earlier commits. ### 1. `hermes gateway status` reported 2 PIDs per gateway — TWO bugs compounded Diagnosed with psutil parent/child walk against live gateway PIDs: **Bug A (the real one): `_get_parent_pid` silently failed on Windows.** The helper shelled out to `ps -o ppid= -p <pid>`, which doesn't exist on Windows — `FileNotFoundError` → returns `None` → the ancestor walk terminated at `os.getpid()` alone. Consequence: the PID table scan in `_scan_gateway_pids` couldn't filter out `hermes gateway status`'s own launcher stub (a venv `pythonw.exe`/`python.exe` that matches the same `-m hermes_cli.main gateway` pattern as the gateway). Every status call saw "itself" as a second gateway. Fix: `_get_parent_pid` now calls `psutil.Process(pid).ppid()` first (psutil is a core dependency since 3dfb35700) and falls back to `ps` only when `shutil.which("ps")` succeeds — matching the Windows-footgun checker's "always guard `ps` / `wmic` / etc. with `shutil.which`" rule. Before: `Gateway process running (PID: 21952, 46880)` — 46880 changing on every call (the status invocation's own launcher, which died by the time the next status call looked). After (5 consecutive calls): ``` ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ``` Ancestor walk on the fix: 14 PIDs (full chain through bash/explorer) instead of the broken 1-PID set. **Bug B (the cosmetic one): venv-launcher dedup.** Standard Windows CPython venv behaviour is that `<venv>/Scripts/pythonw.exe` is a ~5 MB launcher stub that spawns the base Python (`C:\\Program Files\\Python311 \\pythonw.exe`) with the same command line and waits. Our process scanner sees two PIDs for every gateway: launcher + interpreter, same cmdline. Bug A masked this by accidentally counting the status call AS one of them; with Bug A fixed, we see both the real launcher and real interpreter for the gateway process itself. Fix: `_filter_venv_launcher_stubs` at the tail of `_scan_gateway_pids` walks each matched PID's ppid via psutil. Any PID that's the PARENT of another matched PID is a launcher stub — drop it, keep the child. Scoped to Windows (`is_windows() and len(pids) > 1`) and no-ops when psutil isn't importable. Net effect: `gateway status` now reports one PID per gateway — the interpreter — matching POSIX behaviour and user expectations. ### 2. `install.ps1`: bootstrap pip + auto-install platform SDKs New `Install-PlatformSdks` function wired between `Invoke-SetupWizard` and `Start-GatewayIfConfigured`. Fixes two related issues on fresh Windows installs: 1. The tiered `uv pip install` cascade (introduced in 87fca8342) correctly falls through when tier 1 `.[all]` fails on the RL git deps, but the fallback tiers can silently skip SDKs from `[messaging]` when there's a partial-resolve. Result: user sets `DISCORD_BOT_TOKEN` in `.env`, fires up gateway, hits "discord module not installed". 2. `uv` creates venvs WITHOUT pip by default, so the user's escape hatch (`pip install discord.py` in the venv) doesn't exist either. The new function: - Skips if `-NoVenv` (nothing to bootstrap into). - Scans `~/.hermes/.env` for messaging tokens (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, WHATSAPP_ENABLED), filtering placeholder values. - For each token that's set, runs `python -c "import <sdk>"` to verify. - If any import fails: runs `python -m ensurepip --upgrade` to bootstrap pip into the venv (idempotent — no-ops if pip is already present), then `pip install <spec>` for each missing SDK with specs mirroring pyproject.toml's `[messaging]` extra to avoid version drift. The `$ErrorActionPreference = "SilentlyContinue"` spans are not cosmetic — PowerShell wraps native-stderr from a non-zero-exit subprocess as a `NativeCommandError` that prints even through `*> $null` / `2>$null`. Save + restore EAP over the import-probe and pip-install blocks keeps the output clean. Verified on this Windows 10 box: - Initial state: telegram+fastapi+psutil present, discord+slack_sdk missing (tier 1 `.[all]` had failed — `.tirith-install-failed` marker in `%LOCALAPPDATA%\\hermes`). - First run with discord+slack tokens in .env: detects both missing, ensurepip (skipped — pip was already bootstrapped earlier this session for telegram), installs `discord.py[voice]==2.7.1` + `PyNaCl` + `davey`, installs `slack-sdk==3.41.0`. All imports succeed on verify. - Second run: all three SDKs report OK, function no-ops. Pip spec strings mirror pyproject.toml's `[messaging]` extra verbatim so a bump to the extra picks up here automatically — no drift. ### Files - `hermes_cli/gateway.py`: `_get_parent_pid` rewritten (psutil-first); `_filter_venv_launcher_stubs` added; `_scan_gateway_pids` dedups launchers on Windows when it finds >1 match. - `scripts/install.ps1`: new `Install-PlatformSdks` function (~85 lines); wired into the main flow at line 1438. ### Verification - `venv/Scripts/python.exe scripts/check-windows-footguns.py --all` → `✓ No Windows footguns found (380 file(s) scanned).` - `ast.parse` passes on gateway.py. - `[System.Management.Automation.Language.Parser]::ParseFile` passes on install.ps1. - Live gateway (PID 21952, running since 12:33 today) survived 5x stress loop of `hermes gateway status` without dying.
2026-05-08 13:10:34 -07:00
#
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
# for each token set in .env, verify the matching SDK imports. If not,
# run one targeted `pip install` as last-chance recovery. Keeps fresh
# Windows installs from hitting silent "python-telegram-bot not installed"
# at runtime.
if ($NoVenv) {
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
return
}
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
Write-Warn "Skipping platform-SDK verification: $pythonExe not found"
return
}
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) { return }
$envLines = Get-Content $envPath -ErrorAction SilentlyContinue
# Map: env var set in .env -> (import name, pip spec matching [messaging] extra).
# Specs mirror pyproject.toml to avoid version drift.
$sdkMap = @(
@{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" },
@{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" },
@{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" },
@{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" },
@{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" }
)
# Which tokens are actually set (not placeholder)?
$needed = @()
foreach ($sdk in $sdkMap) {
$match = $envLines | Where-Object {
$_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") `
-and $_ -notmatch "your-token-here" `
-and $_ -notmatch "^\s*#"
}
if ($match) { $needed += $sdk }
}
if ($needed.Count -eq 0) { return }
Write-Host ""
Write-Info "Verifying platform SDKs for tokens found in $envPath ..."
# Verify each SDK's import without triggering side-effect imports.
# Quirk: PowerShell wraps non-zero-exit native stderr as a
# NativeCommandError that prints even with `2>$null` / `*> $null`
# unless we set $ErrorActionPreference to SilentlyContinue for the
# span. Save + restore rather than nuking globally.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
$missing = @()
foreach ($sdk in $needed) {
& $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
$missing += $sdk
Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))"
} else {
Write-Success " $($sdk.Import) OK"
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
if ($missing.Count -eq 0) { return }
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
# without pip; ensurepip is the stdlib-blessed way to add it.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
& $pythonExe -m pip --version 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..."
& $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Warn "ensurepip failed -- can't auto-install missing SDKs."
fix(windows): gateway status dedup + install.ps1 platform-SDK bootstrap ## Two residual Windows fixes that were hanging from earlier commits. ### 1. `hermes gateway status` reported 2 PIDs per gateway — TWO bugs compounded Diagnosed with psutil parent/child walk against live gateway PIDs: **Bug A (the real one): `_get_parent_pid` silently failed on Windows.** The helper shelled out to `ps -o ppid= -p <pid>`, which doesn't exist on Windows — `FileNotFoundError` → returns `None` → the ancestor walk terminated at `os.getpid()` alone. Consequence: the PID table scan in `_scan_gateway_pids` couldn't filter out `hermes gateway status`'s own launcher stub (a venv `pythonw.exe`/`python.exe` that matches the same `-m hermes_cli.main gateway` pattern as the gateway). Every status call saw "itself" as a second gateway. Fix: `_get_parent_pid` now calls `psutil.Process(pid).ppid()` first (psutil is a core dependency since 3dfb35700) and falls back to `ps` only when `shutil.which("ps")` succeeds — matching the Windows-footgun checker's "always guard `ps` / `wmic` / etc. with `shutil.which`" rule. Before: `Gateway process running (PID: 21952, 46880)` — 46880 changing on every call (the status invocation's own launcher, which died by the time the next status call looked). After (5 consecutive calls): ``` ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ✓ Gateway process running (PID: 21952) ``` Ancestor walk on the fix: 14 PIDs (full chain through bash/explorer) instead of the broken 1-PID set. **Bug B (the cosmetic one): venv-launcher dedup.** Standard Windows CPython venv behaviour is that `<venv>/Scripts/pythonw.exe` is a ~5 MB launcher stub that spawns the base Python (`C:\\Program Files\\Python311 \\pythonw.exe`) with the same command line and waits. Our process scanner sees two PIDs for every gateway: launcher + interpreter, same cmdline. Bug A masked this by accidentally counting the status call AS one of them; with Bug A fixed, we see both the real launcher and real interpreter for the gateway process itself. Fix: `_filter_venv_launcher_stubs` at the tail of `_scan_gateway_pids` walks each matched PID's ppid via psutil. Any PID that's the PARENT of another matched PID is a launcher stub — drop it, keep the child. Scoped to Windows (`is_windows() and len(pids) > 1`) and no-ops when psutil isn't importable. Net effect: `gateway status` now reports one PID per gateway — the interpreter — matching POSIX behaviour and user expectations. ### 2. `install.ps1`: bootstrap pip + auto-install platform SDKs New `Install-PlatformSdks` function wired between `Invoke-SetupWizard` and `Start-GatewayIfConfigured`. Fixes two related issues on fresh Windows installs: 1. The tiered `uv pip install` cascade (introduced in 87fca8342) correctly falls through when tier 1 `.[all]` fails on the RL git deps, but the fallback tiers can silently skip SDKs from `[messaging]` when there's a partial-resolve. Result: user sets `DISCORD_BOT_TOKEN` in `.env`, fires up gateway, hits "discord module not installed". 2. `uv` creates venvs WITHOUT pip by default, so the user's escape hatch (`pip install discord.py` in the venv) doesn't exist either. The new function: - Skips if `-NoVenv` (nothing to bootstrap into). - Scans `~/.hermes/.env` for messaging tokens (TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN, WHATSAPP_ENABLED), filtering placeholder values. - For each token that's set, runs `python -c "import <sdk>"` to verify. - If any import fails: runs `python -m ensurepip --upgrade` to bootstrap pip into the venv (idempotent — no-ops if pip is already present), then `pip install <spec>` for each missing SDK with specs mirroring pyproject.toml's `[messaging]` extra to avoid version drift. The `$ErrorActionPreference = "SilentlyContinue"` spans are not cosmetic — PowerShell wraps native-stderr from a non-zero-exit subprocess as a `NativeCommandError` that prints even through `*> $null` / `2>$null`. Save + restore EAP over the import-probe and pip-install blocks keeps the output clean. Verified on this Windows 10 box: - Initial state: telegram+fastapi+psutil present, discord+slack_sdk missing (tier 1 `.[all]` had failed — `.tirith-install-failed` marker in `%LOCALAPPDATA%\\hermes`). - First run with discord+slack tokens in .env: detects both missing, ensurepip (skipped — pip was already bootstrapped earlier this session for telegram), installs `discord.py[voice]==2.7.1` + `PyNaCl` + `davey`, installs `slack-sdk==3.41.0`. All imports succeed on verify. - Second run: all three SDKs report OK, function no-ops. Pip spec strings mirror pyproject.toml's `[messaging]` extra verbatim so a bump to the extra picks up here automatically — no drift. ### Files - `hermes_cli/gateway.py`: `_get_parent_pid` rewritten (psutil-first); `_filter_venv_launcher_stubs` added; `_scan_gateway_pids` dedups launchers on Windows when it finds >1 match. - `scripts/install.ps1`: new `Install-PlatformSdks` function (~85 lines); wired into the main flow at line 1438. ### Verification - `venv/Scripts/python.exe scripts/check-windows-footguns.py --all` → `✓ No Windows footguns found (380 file(s) scanned).` - `ast.parse` passes on gateway.py. - `[System.Management.Automation.Language.Parser]::ParseFile` passes on install.ps1. - Live gateway (PID 21952, running since 12:33 today) survived 5x stress loop of `hermes gateway status` without dying.
2026-05-08 13:10:34 -07:00
Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
return
}
}
foreach ($sdk in $missing) {
Write-Info " Installing $($sdk.Spec) ..."
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -eq 0) {
Write-Success " Installed $($sdk.Import)"
} else {
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
}
function Invoke-SetupWizard {
if ($SkipSetup) {
Write-Info "Skipping setup wizard (-SkipSetup)"
return
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
if ($NonInteractive) {
# The setup wizard prompts for API keys, model choice, persona, etc.
# Non-interactive callers (GUI installer) own that UX themselves; let
# them drive it after install.ps1 returns.
Write-Info "Skipping setup wizard (non-interactive). Configure via the GUI or 'hermes setup'."
return
}
Write-Host ""
Write-Info "Starting setup wizard..."
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Push-Location $InstallDir
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Run hermes setup using the venv Python directly (no activation needed)
if (-not $NoVenv) {
& ".\venv\Scripts\python.exe" -m hermes_cli.main setup
} else {
python -m hermes_cli.main setup
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Pop-Location
}
function Start-GatewayIfConfigured {
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) { return }
$hasMessaging = $false
$content = Get-Content $envPath -ErrorAction SilentlyContinue
foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) {
$match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" }
if ($match) { $hasMessaging = $true; break }
}
if (-not $hasMessaging) { return }
2026-02-25 21:04:36 -08:00
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
if (-not (Test-Path $hermesCmd)) {
$hermesCmd = "hermes"
}
# If WhatsApp is enabled but not yet paired, run foreground for QR scan
$whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
$whatsappSession = "$HermesHome\whatsapp\session\creds.json"
if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
Write-Host ""
Write-Info "WhatsApp is enabled but not yet paired."
Write-Info "Running 'hermes whatsapp' to pair via QR code..."
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Non-interactive callers (GUI installer, CI) skip the QR-pair prompt;
# WhatsApp pairing requires a human looking at a phone camera, so the
# downstream UI is responsible for surfacing this when it makes sense.
if (-not $NonInteractive) {
$response = Read-Host "Pair WhatsApp now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") {
try {
& $hermesCmd whatsapp
} catch {
# Expected after pairing completes
}
2026-02-25 21:04:36 -08:00
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
} else {
Write-Info "Skipping WhatsApp pairing prompt (non-interactive)."
2026-02-25 21:04:36 -08:00
}
}
Write-Host ""
Write-Info "Messaging platform token detected!"
Write-Info "The gateway handles messaging platforms and cron job execution."
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# In non-interactive mode the gateway lifecycle is the caller's problem
# (the GUI manages its own gateway process, CI doesn't want background
# services on the build agent, etc.). Treat it like the user declined.
if ($NonInteractive) {
Write-Info "Skipping gateway autostart prompt (non-interactive)."
Write-Info "Start the gateway later with: hermes gateway"
return
}
$response = Read-Host "Would you like to start the gateway now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") {
Write-Info "Starting gateway in background..."
try {
$logFile = "$HermesHome\logs\gateway.log"
Start-Process -FilePath $hermesCmd -ArgumentList "gateway" `
-RedirectStandardOutput $logFile `
-RedirectStandardError "$HermesHome\logs\gateway-error.log" `
-WindowStyle Hidden
Write-Success "Gateway started! Your bot is now online."
Write-Info "Logs: $logFile"
Write-Info "To stop: close the gateway process from Task Manager"
} catch {
Write-Warn "Failed to start gateway. Run manually: hermes gateway"
}
} else {
Write-Info "Skipped. Start the gateway later with: hermes gateway"
}
}
function Write-Completion {
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "+---------------------------------------------------------+" -ForegroundColor Green
Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "+---------------------------------------------------------+" -ForegroundColor Green
Write-Host ""
# Show file locations
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "* Your files:" -ForegroundColor Cyan
Write-Host ""
Write-Host " Config: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\config.yaml"
Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\.env"
Write-Host " Data: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\cron\, sessions\, logs\"
Write-Host " Code: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\hermes-agent\"
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "* Commands:" -ForegroundColor Cyan
Write-Host ""
Write-Host " hermes " -NoNewline -ForegroundColor Green
Write-Host "Start chatting"
Write-Host " hermes setup " -NoNewline -ForegroundColor Green
Write-Host "Configure API keys & settings"
Write-Host " hermes config " -NoNewline -ForegroundColor Green
Write-Host "View/edit configuration"
Write-Host " hermes config edit " -NoNewline -ForegroundColor Green
Write-Host "Open config in editor"
Write-Host " hermes gateway " -NoNewline -ForegroundColor Green
Write-Host "Start messaging gateway (Telegram, Discord, etc.)"
Write-Host " hermes update " -NoNewline -ForegroundColor Green
Write-Host "Update to latest version"
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
Write-Host ""
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow
Write-Host ""
if (-not $HasNode) {
Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow
Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow
Write-Host " https://nodejs.org/en/download/" -ForegroundColor Yellow
Write-Host ""
}
if (-not $HasRipgrep) {
Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow
Write-Host " winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow
Write-Host ""
}
}
# ============================================================================
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Stage protocol
# ============================================================================
#
# install.ps1 supports a small, stable "stage protocol" that lets programmatic
# callers (the desktop GUI's onboarding wizard, CI, future install.sh, etc.)
# drive the install one step at a time and surface progress/errors with their
# own UI. CLI users running the canonical `irm | iex` one-liner never
# encounter this -- default invocation behaves exactly as before.
#
# Entry points:
#
# install.ps1 Interactive install (today's behavior).
# install.ps1 -ProtocolVersion Emit the protocol version integer.
# install.ps1 -Manifest Emit the stage manifest as JSON.
# install.ps1 -Stage <name> Run one stage and emit its result.
# install.ps1 -NonInteractive Disable all Read-Host prompts (also
# skips the setup wizard and the gateway
# autostart prompt). Can be combined
# with default invocation to do a full
# non-interactive install.
# install.ps1 -Json Emit machine-readable JSON instead of
# the human-readable success banner at
# the end of a full install.
#
# Manifest schema (the JSON returned by -Manifest):
#
# {
# "protocol_version": 1,
# "stages": [
# {
# "name": "uv",
# "title": "Installing uv package manager",
# "category": "prereqs",
# "needs_user_input": false
# },
# ...
# ]
# }
#
# Stage result (the JSON written by -Stage <name>):
#
# {
# "stage": "uv",
# "ok": true,
# "skipped": false,
# "reason": null,
# "duration_ms": 1234
# }
#
# Exit codes:
#
# 0 -- success (stage ran, or stage was deliberately skipped).
# 1 -- generic failure; the stage threw.
# 2 -- unknown stage name passed to -Stage.
#
# Adding a stage:
#
# 1. Append an entry to $InstallStages below.
# 2. Make sure the worker function it points at is idempotent and respects
# $NonInteractive when it has prompts. Add it before "configure"
# (the wizard) or "gateway" (autostart) if it should run unconditionally;
# after those if it's optional post-install glue.
# 3. Do NOT bump $InstallStageProtocolVersion -- adding stages is additive.
# Drivers iterate the manifest dynamically.
#
# ============================================================================
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# Stage definitions -- the single source of truth. Each entry maps a stable
# stage name (the API contract drivers depend on) to the worker function that
# implements it. ``Title`` is what UIs show; ``Category`` lets UIs group
# stages; ``NeedsUserInput`` tells UIs "this stage prompts -- either skip it
# or arrange to provide answers another way."
$InstallStages = @(
@{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" }
@{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
@{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" }
@{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" }
@{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" }
@{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" }
@{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" }
@{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" }
@{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" }
@{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" }
@{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" }
@{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" }
# Interactive stages. In non-interactive mode these become no-ops; the
# caller (GUI / CI) handles the equivalent UX themselves.
@{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" }
@{ Name = "gateway"; Title = "Starting messaging gateway"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Gateway" }
)
# Stage workers -- thin wrappers that delegate to the existing Install-* /
# Test-* / Invoke-* functions while preserving their error semantics. Kept
# as a separate layer so the existing functions remain callable directly
# (helpful for one-off recovery: ``. install.ps1; Install-Venv``).
#
# Stages that depend on uv (anything after Stage-Uv) call Resolve-UvCmd
# first so they work in cross-process driver mode where $script:UvCmd
# set by Stage-Uv in a sibling powershell process is not visible here.
# Resolve-UvCmd is a fast no-op when $script:UvCmd is already populated
# (the default-invocation case where Main runs everything in one
# process), and throws cleanly if uv truly isn't installed yet.
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" } }
# Node is optional (browser tools degrade gracefully without it). Surface
# failure to the JSON contract as skipped=true / reason rather than ok=true,
# so a GUI driver consuming the manifest can distinguish "node ready" from
# "node missing". Install flow continues either way -- matches the
# existing Write-Completion behavior that prints a "Note: Node.js could
# not be installed" hint instead of aborting.
function Stage-Node {
if (-not (Test-Node)) {
$script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/"
}
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
function Stage-SystemPackages { Install-SystemPackages }
function Stage-Repository { Install-Repository }
function Stage-Venv { Resolve-UvCmd; Install-Venv }
function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies }
function Stage-NodeDeps { Install-NodeDeps }
function Stage-Path { Set-PathVariable }
function Stage-ConfigTemplates { Copy-ConfigTemplates }
function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks }
function Stage-Configure { Invoke-SetupWizard }
function Stage-Gateway { Start-GatewayIfConfigured }
function Get-InstallStage {
param([string]$Name)
foreach ($s in $InstallStages) {
if ($s.Name -eq $Name) { return $s }
}
return $null
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
function Step-OutOfInstallDir {
# Windows refuses to delete a directory any shell is currently cd'd
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
# inside -- and silently leaves orphan files behind, which then wedge
# "is this a valid git repo" probes on re-install. Harmless when the
# caller ran the installer from somewhere else.
try {
$currentResolved = (Get-Location).ProviderPath
$installResolved = $null
if (Test-Path $InstallDir) {
$installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath
}
if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) {
Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..."
Set-Location $env:USERPROFILE
}
} catch {}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
}
function Invoke-Stage {
param(
[Parameter(Mandatory=$true)] [hashtable]$StageDef
)
# Refresh PATH from registry so this stage sees binaries installed by
# prior stages, even when each stage runs in its own powershell process.
# No-op in cost-relevant cases (default invocation path syncs once per
# foreach pass; cross-process drivers get the necessary freshening).
Sync-EnvPath
# Per-stage soft-skip channel. A worker can populate
# $script:_StageSkippedReason to surface "ran, but the thing it was
# supposed to set up is not available" as skipped=true in the JSON
# frame, without throwing. Used by Stage-Node so the install flow
# doesn't abort when an optional capability is missing while still
# being honest in the protocol contract. Reset before each stage so
# a prior stage's reason can never leak into a later stage's frame.
$script:_StageSkippedReason = $null
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$start = [DateTime]::UtcNow
$result = @{
stage = $StageDef.Name
ok = $false
skipped = $false
reason = $null
duration_ms = 0
}
try {
& $StageDef.Worker
$result.ok = $true
if ($script:_StageSkippedReason) {
$result.skipped = $true
$result.reason = $script:_StageSkippedReason
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
} 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
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# Tell the entry-point catch that we've already emitted a
# frame for this failure (when $result.ok = $false), so it
# doesn't double-emit a second JSON object and break the
# one-line-per-stage contract the driver protocol promises.
if (-not $result.ok) {
$script:_StageEmittedErrorFrame = $true
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
}
}
}
# ============================================================================
# Main
# ============================================================================
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
function Invoke-AllStages {
Step-OutOfInstallDir
foreach ($s in $InstallStages) {
Invoke-Stage -StageDef $s
}
}
function Invoke-EnsureMode {
param([string]$Deps)
$depList = $Deps -split ","
foreach ($dep in $depList) {
$dep = $dep.Trim()
switch ($dep) {
"node" {
[void](Test-Node)
if (-not $script:HasNode) {
Write-Err "Node.js could not be installed"
exit 1
}
}
"browser" {
[void](Test-Node)
if ($script:HasNode) {
Install-AgentBrowser
} else {
Write-Err "Node.js is required for browser tools but could not be installed"
exit 1
}
}
"ripgrep" {
Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)"
}
"ffmpeg" {
Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)"
}
default {
Write-Err "Unknown dependency: $dep"
exit 1
}
}
}
}
function Invoke-PostInstallMode {
Write-Info "Running post-install setup..."
Invoke-EnsureMode -Deps "node,browser"
Write-Info "Post-install complete"
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
function Main {
Write-Banner
Invoke-AllStages
if (-not $Json) {
Write-Completion
} else {
@{ ok = $true; protocol_version = $InstallStageProtocolVersion } | ConvertTo-Json -Compress | Write-Output
}
}
# ----------------------------------------------------------------------------
# Entry-point dispatch
# ----------------------------------------------------------------------------
#
# All branches funnel through one try/catch so errors don't kill an `irm |
# iex` PowerShell session, and so failures in stage-driver mode produce a
# structured JSON error frame instead of a bare exception.
try {
if ($Ensure -ne "") {
if ($PSBoundParameters.ContainsKey("Stage")) {
Write-Err "Cannot use -Ensure and -Stage simultaneously"
exit 1
}
Invoke-EnsureMode -Deps $Ensure
exit 0
}
if ($PostInstall) {
Invoke-PostInstallMode
exit 0
}
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
if ($ProtocolVersion) {
Write-Output $InstallStageProtocolVersion
exit 0
}
if ($Manifest) {
$payload = @{
protocol_version = $InstallStageProtocolVersion
stages = @($InstallStages | ForEach-Object {
@{
name = $_.Name
title = $_.Title
category = $_.Category
needs_user_input = $_.NeedsUserInput
}
})
}
$payload | ConvertTo-Json -Depth 5 -Compress | Write-Output
exit 0
}
# Use PSBoundParameters rather than $Stage truthiness so that an
# explicit `-Stage ""` from a misbehaving driver doesn't fall through
# to the full-install Main path and silently kick off a destructive
# operation. Empty string is a contract violation; surface it as
# unknown-stage exit 2 with a structured JSON frame.
if ($PSBoundParameters.ContainsKey("Stage")) {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
$def = Get-InstallStage -Name $Stage
if (-not $def) {
$err = @{
ok = $false
stage = $Stage
reason = "unknown stage: $Stage. Run install.ps1 -Manifest to list valid stages."
}
$err | ConvertTo-Json -Compress | Write-Output
exit 2
}
Step-OutOfInstallDir
Invoke-Stage -StageDef $def
exit 0
}
# Default: full install (today's behavior, plus optional -NonInteractive
# and -Json layered on by the params above).
Main
} catch {
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
if ($Json -or $Stage) {
# Stage-driver mode: caller wants JSON they can parse. Emit a
fix(install.ps1): address Copilot review on #27224 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.
2026-05-17 01:23:59 -04:00
# structured error frame and exit non-zero -- BUT only if
# Invoke-Stage didn't already emit one for this same failure.
# The inner finally emits the authoritative per-stage frame
# (with duration_ms + skipped fields); a second emit here
# would produce two concatenated JSON objects on stdout and
# break drivers that parse one-line-per-invocation.
if (-not $script:_StageEmittedErrorFrame) {
$err = @{
ok = $false
stage = if ($Stage) { $Stage } else { $null }
reason = "$_"
}
$err | ConvertTo-Json -Compress | Write-Output
feat(install.ps1): stage protocol + Windows clean-VM hardening pass Adds an opt-in stage protocol that lets programmatic drivers (the desktop GUI's onboarding wizard, CI, future install.sh parity) drive install.ps1 one step at a time with structured JSON results. Default invocation (`irm | iex` one-liner) behaves unchanged. Entry points: install.ps1 Today's interactive install (unchanged) install.ps1 -ProtocolVersion Emit protocol version integer install.ps1 -Manifest Emit JSON manifest of available stages install.ps1 -Stage <name> Run one stage, emit JSON result install.ps1 -NonInteractive Suppress Read-Host prompts (skips the setup wizard and gateway autostart) install.ps1 -Json Machine-readable completion frame Manifest exposes 14 stages across prereqs/install/finalize/post-install categories, with 2 (configure, gateway) flagged needs_user_input=true so GUI drivers can skip them and handle the equivalent UX themselves. Along the way, clean-VM testing on stock Windows 10/11 surfaced a series of latent install.ps1 bugs that were never exercised by developer machines. Fixed in the same commit: * Encoding: file is now pure ASCII with no BOM. Windows PowerShell 5.1 reads BOM-less files as Windows-1252 and chokes on em-dashes (and other UTF-8 sequences), while iex chokes on a leading U+FEFF. Pure-ASCII satisfies both invocation paths. * EAP=Stop + native `2>&1` captures: PowerShell wraps stderr lines from native commands as ErrorRecord objects under EAP=Stop and throws even when the command exits 0. Relaxed to EAP=Continue around the astral.sh uv installer, `uv python install`, `npm install`, `npx playwright install`, the venv import probes, and the Node winget fallback. Check $LASTEXITCODE for the real signal. * Cross-process state: each `-Stage <name>` invocation spawns a fresh powershell child. $script:UvCmd set by Stage-Uv was invisible to Stage-Python; PATH updated by Stage-Git/Stage-Node was invisible to subsequent stages spawned by the driver shell. Added Resolve-UvCmd helper called at the top of every stage that needs uv, and a Sync-EnvPath helper called at the top of Invoke-Stage to refresh PATH from the registry. * UAC avoidance: `winget install OpenJS.NodeJS.LTS` triggers a UAC prompt that often appears minimized in the taskbar -- looks like a hang. Switched Test-Node to prefer the official portable Node zip dropped into %LOCALAPPDATA%\hermes\node\ (mirrors the PortableGit pattern Install-Git already uses). winget kept as fallback. * npx hangs on confirmation: `npx playwright install chromium` blocks on stdin waiting for "Need to install playwright@X.Y.Z (y/N)" when playwright isn't in local node_modules. Tee-Object pipelines disconnect stdin from the user's TTY so the install hangs forever. Pass `--yes` to auto-accept. * Silent long-running installs: `*> $logPath` redirected every stream to disk and left the user staring at a frozen "Installing..." line for the 5-10 minutes Playwright Chromium takes to download. Switched to `2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $log` so output streams live to the console AND captures to log for failure diagnostics. ForEach-Object coercion strips PowerShell's red NativeCommandError formatter from stderr items. * Console encoding: forced [Console]::OutputEncoding to UTF-8 so playwright/git/npm progress bars, box-drawing, and check marks render correctly instead of as IBM437/Windows-1252 mojibake. * Performance: set $ProgressPreference = "SilentlyContinue" so Invoke-WebRequest doesn't paint its per-chunk progress bar. The PS 5.1 progress UI throttles downloads by 10-100x (a 57MB PortableGit grab takes 5 minutes with the bar on vs ~20 seconds with it off, same network). Affects PortableGit, Node portable zip, and the Hermes repo zip fallback. Tests: scripts/tests/test-install-ps1-stage-protocol.ps1 provides 19 metadata-only assertions covering -ProtocolVersion, -Manifest schema, and unknown -Stage error frame. No install side effects. End-to-end validated on a clean Windows 10 VM via: 1. `irm <branch>/scripts/install.ps1 | iex` (canonical CLI path) 2. `powershell -File install.ps1 -Stage X` iterated through every stage (GUI driver path, exercises cross-process fixes)
2026-05-17 00:47:21 -04:00
}
exit 1
}
# Interactive mode: keep today's friendly recovery hint.
Write-Host ""
Write-Err "Installation failed: $_"
Write-Host ""
Write-Info "If the error is unclear, try downloading and running the script directly:"
Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
Write-Host " .\install.ps1" -ForegroundColor Yellow
Write-Host ""
}