# ============================================================================ # Hermes Agent Installer for Windows # ============================================================================ # Installation script for Windows (PowerShell). # Uses uv for fast Python provisioning and package management. # # Usage: # iex (irm https://hermes-agent.nousresearch.com/install.ps1) # # Or download and run with options: # .\install.ps1 -NoVenv -SkipSetup # # ============================================================================ param( [switch]$NoVenv, [switch]$SkipSetup, [string]$Branch = "main", # -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 = "", # Apply -Commit even when it would roll an existing install BACKWARDS. # Without this the repository stage skips a pin that is already an ancestor # of HEAD, so a stale baked-in BUILD_PIN_COMMIT can't downgrade a current # checkout. Reproducible/CI installs that genuinely want an older SHA on an # existing tree pass -ForceCommit. [switch]$ForceCommit, [string]$Tag = "", [string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }), [string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$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, # Print the paths this install would use, as JSON, and exit without # touching anything. The first question on any "installer says a path # doesn't exist" report is which paths it actually resolved -- especially # on profiles Windows exposes through an 8.3 alias, where what the user # sees in Explorer and what the installer receives differ. # # powershell -File install.ps1 -ShowResolvedPaths [switch]$ShowResolvedPaths, # --- Ensure mode (dep_ensure.py entry point) --- [string]$Ensure = "", [switch]$PostInstall, # --- Desktop GUI build (opt-in) --- # When set, install.ps1 includes Stage-Desktop in the manifest and # builds apps/desktop into a launchable Hermes.exe. # # Why opt-in: # * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes # -IncludeDesktop so a user who installed via the GUI ends up # with a launchable desktop binary. # * The Electron desktop's own bootstrap-runner.ts runs install.ps1 # from inside an already-launched Hermes.exe; if THAT recursively # built apps/desktop it would try to overwrite the live Hermes.exe # on disk and fail. The recursive path omits the flag. # * The canonical CLI one-liner (irm | iex) omits the flag too; # terminal users don't need a desktop binary built for them, and # `hermes desktop` already builds on demand. [switch]$IncludeDesktop ) $ErrorActionPreference = "Stop" # 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. } # ============================================================================ # 8.3 short-path normalization # ============================================================================ # Windows generates an 8.3 short alias for a user-profile folder whose name # contains a space ("First Last" -> FIRST~1.LAS), a dot ("Stone.ZEN8" -> # STONE~1.ZEN), or an accented character ("Ruben" spelled with an acute e -> # RUBN~1). It can then expose %TEMP%, %TMP%, %LOCALAPPDATA%, %APPDATA% and # %USERPROFILE% -- plus everything derived from them, including the default # HERMES_HOME and InstallDir -- in that short form: # C:\Users\FIRST~1.LAS\AppData\Local\Temp # # PowerShell's FileSystem provider mishandles the aliased component when such a # path reaches a provider cmdlet (`Tee-Object -FilePath`, `Out-File`, # `New-Item`, `Test-Path`), throwing "An object at the specified path # C:\Users\FIRST~1.LAS does not exist" -- localized on non-English hosts. # Every Node/Electron stage streams its build log to %TEMP% via Tee-Object and # the desktop stage probes the binary it produced under the profile-derived # InstallDir, so the bootstrap aborts even though the artifact built fine. # The Python/uv stages, which never hand a %TEMP% path to a provider cmdlet, # sail through -- which is why the failure looks Node-specific. # # Expanding every profile-rooted path back to long form once, up front, lets # every downstream cmdlet and child process see something the provider can # resolve. Three resolvers, tried in order, because no single one covers every # host: # # 1. kernel32!GetLongPathNameW -- expands any 8.3 component regardless of # locale, including the accented-username aliases the COM resolver misses. # 2. Scripting.FileSystemObject -- fallback for hosts where P/Invoke is # blocked. # 3. Profile-root substitution -- when the volume has 8.3 generation disabled # or the alias is stale, neither resolver can expand the name because it # no longer maps to anything on disk. The aliased component is always the # profile folder itself (everything below it was created long), so swap in # a profile root we can prove is long and reattach the tail. # # All three degrade to returning the input untouched, so a host where none of # them apply -- including non-Windows -- behaves exactly as it did before. $script:LongProfileRoot = $null function Write-PathDiag { # Diagnostics for this block go to stderr, never stdout: the stage protocol # hands drivers a single line of JSON on stdout and a stray note would break # anything parsing it. # # Suppressed entirely under -ShowResolvedPaths, which is a machine-readable # query: Windows PowerShell 5.1 wraps any native-command stderr in a # NativeCommandError and folds it back into the caller's own stream, so a # child writing here at all is enough to corrupt a 5.1 caller's capture. # The JSON already carries everything these lines say. # # [Console]::Error.WriteLine specifically -- verified reaching a caller on a # windows-latest runner. $host.UI.WriteErrorLine was tried and silently # produced nothing there under a non-interactive host. param([string]$Message) if ($ShowResolvedPaths) { return } [Console]::Error.WriteLine("[hermes] $Message") } function Get-LongProfileRoot { # The user's profile directory in long form, or '' when every source we # can reach is itself aliased. Cached: this runs per env var. if ($null -ne $script:LongProfileRoot) { return $script:LongProfileRoot } $script:LongProfileRoot = '' # %USERPROFILE% first: it is what the rest of the install derives from, and # on a host handing us aliased paths the .NET known-folder lookup tends to # be aliased in exactly the same way. Then the HOMEDRIVE/HOMEPATH pair, then # the profile's parent (C:\Users never carries an alias) plus %USERNAME%, # which stays the long account name even when every path is short. $envProfile = [Environment]::GetEnvironmentVariable('USERPROFILE') $shellProfile = [Environment]::GetFolderPath('UserProfile') $candidates = @($envProfile, $shellProfile, "$env:HOMEDRIVE$env:HOMEPATH") foreach ($anchor in @($envProfile, $shellProfile)) { if ($anchor -and $env:USERNAME) { $parent = Split-Path -Parent $anchor.TrimEnd('\', '/') if ($parent) { $candidates += (Join-Path $parent $env:USERNAME) } } } foreach ($candidate in $candidates) { if ([string]::IsNullOrWhiteSpace($candidate)) { continue } # Trailing separators make Split-Path -Parent return the directory # itself, which would silently break the ancestry check downstream. $candidate = $candidate.TrimEnd('\', '/') if (-not $candidate) { continue } if ($candidate -match '~\d') { continue } try { if (Test-Path -LiteralPath $candidate -PathType Container) { $script:LongProfileRoot = $candidate break } } catch { # Unreadable candidate (denied, malformed): try the next one. } } # Say which root we landed on. When someone reports "still broken" this is # the first thing worth knowing, and it costs one line on the rare path # where an alias actually showed up. if ($script:LongProfileRoot) { Write-PathDiag "long profile root: $script:LongProfileRoot" } else { Write-PathDiag "no long profile root found; 8.3 paths left as-is (tried: $($candidates -join ', '))" } return $script:LongProfileRoot } function Expand-ShortProfileRoot { # Rebuild $Path onto a known-long profile root when its aliased component # is the profile folder. Returns $Path unchanged when it isn't, so a custom # TEMP on another volume (D:\SHORT~1\Temp) is never rewritten. param([string]$Path) $longRoot = Get-LongProfileRoot if (-not $longRoot) { return $Path } $longRootParent = Split-Path -Parent $longRoot if (-not $longRootParent) { return $Path } $node = $Path $tail = '' while ($node -and ($node -match '~\d')) { $leaf = Split-Path -Leaf $node $parent = Split-Path -Parent $node if (-not $parent) { return $Path } if ($leaf -match '~\d') { # Candidate profile folder. Only substitute when it sits in the # same directory as the real profile (both C:\Users). if ($parent -ne $longRootParent) { return $Path } if ($tail) { return (Join-Path $longRoot $tail) } return $longRoot } $tail = if ($tail) { Join-Path $leaf $tail } else { $leaf } $node = $parent } return $Path } function ConvertTo-LongPath { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } # Only 8.3 short names carry a tilde+digit ("~1"); skip every resolver for # ordinary long paths, which is the overwhelmingly common case. if ($Path -notmatch '~\d') { return $Path } # 1. kernel32. Compiled on first use only, so a normal profile never pays # the Add-Type cost (this file is re-entered once per install stage). try { if (-not ([System.Management.Automation.PSTypeName]'HermesInstall.LongPath').Type) { Add-Type -Namespace 'HermesInstall' -Name 'LongPath' -MemberDefinition @' [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern int GetLongPathNameW(string lpszShortPath, System.Text.StringBuilder lpszLongPath, int cchBuffer); '@ } $buffer = New-Object System.Text.StringBuilder 4096 $length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity) if ($length -gt $buffer.Capacity) { $buffer = New-Object System.Text.StringBuilder $length $length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity) } if ($length -gt 0) { $expanded = $buffer.ToString() if ($expanded -and $expanded -notmatch '~\d') { $script:LastResolver = 'kernel32' return $expanded } } } catch { # Not Windows, or P/Invoke denied by policy: try the next resolver. } # 2. COM. Validate the result the same way the kernel32 branch does: this # resolver can report success and still hand back a path that carries the # alias (observed on a windows-latest runner, where it "resolved" # C:\Users\FIRST~1.LAS\... to itself). Accepting that silently is what let a # short path reach the provider cmdlets in the first place, so an # unexpanded result counts as failure and falls through. try { $fso = New-Object -ComObject Scripting.FileSystemObject $resolved = $null if ($fso.FolderExists($Path)) { $resolved = $fso.GetFolder($Path).Path } elseif ($fso.FileExists($Path)) { $resolved = $fso.GetFile($Path).Path } if ($resolved -and $resolved -notmatch '~\d') { $script:LastResolver = 'com' return $resolved } } catch { # COM unavailable / locked-down host: try the next resolver. } # 3. The alias resolves to nothing. Rebuild from a long profile root. $rebuilt = Expand-ShortProfileRoot $Path $script:LastResolver = if ($rebuilt -ne $Path) { 'profile-root' } else { 'none' } return $rebuilt } function Set-LongProfileEnvVars { # Normalize every profile-rooted variable the install reads, not just # %TEMP%: the desktop stage derives InstallDir from %LOCALAPPDATA%, and a # short root there fails the post-build probe after a successful build. # Returns $true when anything was rewritten. $rewrote = $false $script:NormalizedPathRewrites = @{} foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) { $current = [Environment]::GetEnvironmentVariable($name) if (-not $current) { continue } $expanded = ConvertTo-LongPath $current if ($expanded -and $expanded -ne $current) { Set-Item -Path "Env:$name" -Value $expanded $rewrote = $true $script:NormalizedPathRewrites[$name] = $expanded # Rewriting a profile path is rare and corrective; say so. Every # report of this bug class arrived as a bare "does not exist" with # no hint that a short alias was involved. stderr, so the stage # protocol's stdout JSON stays parseable. Write-PathDiag "expanded 8.3 short path in %$name%: $current -> $expanded" } } return $rewrote } $script:NormalizedProfilePaths = Set-LongProfileEnvVars # Re-derive the install paths now that the env vars behind their defaults are # long. An explicitly passed -HermesHome / -InstallDir is normalized in place # rather than replaced, so a caller's choice is never overwritten by a default. # $PSBoundParameters is only meaningful at script scope, so this stays inline. if ($PSBoundParameters.ContainsKey('HermesHome')) { $HermesHome = ConvertTo-LongPath $HermesHome } else { $HermesHome = ConvertTo-LongPath $( if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" } ) } if ($PSBoundParameters.ContainsKey('InstallDir')) { $InstallDir = ConvertTo-LongPath $InstallDir } else { $InstallDir = ConvertTo-LongPath $( if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" } ) } if ($script:NormalizedProfilePaths) { # Which paths the install actually settled on. Absent from every report of # this bug class, and the whole question once a short alias is in play. Write-PathDiag "resolved install paths: HermesHome=$HermesHome InstallDir=$InstallDir" } # Captured here, where the values are final, and emitted from the entry-point # dispatch at the bottom (alongside -ProtocolVersion / -Manifest) so # -ShowResolvedPaths exits before any stage runs. # # The report goes to STDOUT as JSON: on Windows a child's stderr does not # reliably reach a parent process -- three separate capture mechanisms each came # back empty on a windows-latest runner while stdout arrived intact -- and the # first question on any "installer says a path doesn't exist" report is which # paths it actually resolved. $script:ResolvedPathReport = @{ long_profile_root = (Get-LongProfileRoot) normalized = $script:NormalizedPathRewrites resolver = $script:LastResolver temp = $env:TEMP hermes_home = $HermesHome install_dir = $InstallDir } # ============================================================================ # Configuration # ============================================================================ $RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git" $RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git" $PythonVersion = "3.11" # Minor versions the installer accepts when the requested $PythonVersion isn't # available, in preference order. uv discovers both uv-managed and system # interpreters, so this list also matches a pre-existing system Python. Single # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") $NodeVersion = "22" # The npm range the root package.json pins in `engines.npm`. A constant rather # than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo # is cloned, so there is usually no package.json on disk yet (and none at all # when install.ps1 is piped straight from the web). Get-NpmRange prefers the # manifest whenever it does exist, so a drifted constant self-corrects on any # run against an existing checkout. $NpmRange = ">=12.0.0" # 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 # Return the real OS processor architecture as a lowercase string suitable for # Node.js / electron download URL slugs: "arm64", "x64", or "x86". # # Why not just trust [Environment]::Is64BitOperatingSystem or # [RuntimeInformation]::OSArchitecture? On Windows on ARM, when this script # is invoked from Windows PowerShell 5.1 (the default `powershell.exe`) or # any x64 PowerShell host, the process runs under Prism x64 emulation and # BOTH of those APIs report `X64` -- they describe the emulated view, not # the real OS. We've seen this concretely on Snapdragon X1 hardware: an # ARM64-based Surface Laptop returns OSArchitecture=X64 from an emulated # PowerShell session. # # Win32_Processor.Architecture is invariant to emulation. Values: # 0=x86, 5=ARM, 9=AMD64/x64, 12=ARM64. We fall back to # PROCESSOR_ARCHITEW6432 (set on WoW64 with the real OS arch) and then # PROCESSOR_ARCHITECTURE so we still produce a sensible answer if CIM # isn't available (locked-down WMI, container, etc.). function Get-WindowsArch { try { $proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1 switch ([int]$proc.Architecture) { 12 { return "arm64" } 9 { return "x64" } 0 { return "x86" } 5 { return "arm" } } } catch { # CIM unavailable -- fall through to env-var path } $envArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } switch ($envArch) { "ARM64" { return "arm64" } "AMD64" { return "x64" } "x86" { return "x86" } default { # Last-resort: respect 64-bitness so we don't ship a 32-bit # toolchain to anyone. if ([Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" } } } } # ============================================================================ function Write-Banner { Write-Host "" 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) Write-Host "-> $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warn { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } function Write-Err { param([string]$Message) Write-Host "[X] $Message" -ForegroundColor Red } function Invoke-NativeWithRelaxedErrorAction { param([scriptblock]$Script) $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" try { & $Script } finally { $ErrorActionPreference = $prevEAP } } function Discard-LockfileChurn { param([string]$Repo = $InstallDir) if (-not $Repo -or -not (Test-Path (Join-Path $Repo ".git"))) { return } try { $diff = & git -c windows.appendAtomically=false -C $Repo diff --name-only 2>$null if ($LASTEXITCODE -ne 0 -or -not $diff) { return } $dirtyPackageDirs = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($path in $diff) { if ($path -like "*package.json") { $null = $dirtyPackageDirs.Add((Split-Path $path -Parent)) } } $dirtyLocks = [System.Collections.Generic.List[string]]::new() foreach ($path in $diff) { if ($path -notlike "*package-lock.json") { continue } $lockDir = Split-Path $path -Parent if ($dirtyPackageDirs.Contains($lockDir)) { continue } $dirtyLocks.Add($path) } if ($dirtyLocks.Count -eq 0) { return } & git -c windows.appendAtomically=false -C $Repo checkout -- @($dirtyLocks) 2>$null if ($LASTEXITCODE -eq 0) { Write-Info "Discarded npm lockfile churn ($($dirtyLocks.Count) file(s))" } } catch { # Best-effort only; never let cleanup block the installer update path. } } # Inspect npm output for a TLS-trust failure and, if found, print actionable # remediation. npm/Node surface corporate MITM proxies and missing root CAs as # "unable to get local issuer certificate" / "self-signed certificate in # certificate chain" / UNABLE_TO_GET_ISSUER_CERT_LOCALLY -- most commonly while # Electron's install.js postinstall downloads the Electron binary. The reporter # usually misreads this as an admin-rights or generic install failure (see # issue #38016), so detect it once here and route every npm stage through this # hint. Returns $true when a cert error was detected (caller may adjust its own # messaging), $false otherwise. function Show-NpmCertHint { param([string]$NpmOutput) if (-not $NpmOutput) { return $false } $isCertError = $NpmOutput -match "unable to get local issuer certificate" ` -or $NpmOutput -match "self.signed certificate" ` -or $NpmOutput -match "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" ` -or $NpmOutput -match "SELF_SIGNED_CERT_IN_CHAIN" ` -or $NpmOutput -match "CERT_HAS_EXPIRED" if (-not $isCertError) { return $false } Write-Warn "This looks like a TLS certificate-trust failure, not a permissions problem." Write-Info " A corporate proxy or antivirus is likely intercepting HTTPS and presenting a" Write-Info " certificate Node.js doesn't trust. To fix, point Node at your org's root CA:" Write-Info " 1. Get the corporate root CA as a .pem/.crt from your IT team." Write-Info " 2. setx NODE_EXTRA_CA_CERTS `"C:\path\to\corp-ca.pem`"" Write-Info " 3. Open a NEW terminal (so the env var takes effect) and re-run the installer." Write-Info " Quick (less secure) alternative -- disable TLS verification just for the install:" Write-Info " npm config set strict-ssl false (re-enable afterwards: npm config set strict-ssl true)" return $true } function Write-NpmDebugLogTail { # On failure npm prints only a terse summary to stdout/stderr; the real # evidence (postinstall script stderr like Electron's install.js, network # traces, EBUSY retries) lives in npm's own debug log under # \_logs\-debug-0.log. The bootstrap installer's # streaming sink only captures what WE emit, so on any npm failure this # helper locates that debug log and replays its tail into our output # stream -- making the bootstrap log a self-contained diagnosis instead # of "exit 1, details in a file on a VM nobody can reach". param( [string]$NpmOutput, [int]$TailLines = 200 ) $logPath = $null # Preferred: npm names the exact file in its failure summary. if ($NpmOutput -and $NpmOutput -match "A complete log of this run can be found in:\s*(?[^\r\n]+)") { $candidate = $Matches['path'].Trim() if (Test-Path -LiteralPath $candidate) { $logPath = $candidate } } # Fallback (covers --silent runs, truncated output): newest debug log in # npm's cache _logs directory. if (-not $logPath) { try { $npm = Resolve-NpmCmd if ($npm) { $prevEAPLocal = $ErrorActionPreference $ErrorActionPreference = "Continue" $cacheDir = (& $npm config get cache 2>$null | Select-Object -Last 1) $ErrorActionPreference = $prevEAPLocal if ($cacheDir) { $logsDir = Join-Path ("$cacheDir").Trim() "_logs" if (Test-Path -LiteralPath $logsDir) { $newest = Get-ChildItem -LiteralPath $logsDir -Filter "*-debug-*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($newest) { $logPath = $newest.FullName } } } } } catch { } } if (-not $logPath) { Write-Warn "npm debug log could not be located -- no further npm detail available" return } $tail = $null try { $tail = Get-Content -LiteralPath $logPath -Tail $TailLines -ErrorAction Stop } catch { Write-Warn "Could not read npm debug log ${logPath}: $($_.Exception.Message)" return } Write-Warn "---- npm debug log: last $TailLines lines of $logPath ----" foreach ($line in $tail) { Write-Host " $line" -ForegroundColor DarkGray } Write-Warn "---- end npm debug log ----" } # --- 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 { # Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override. # # We no longer scan well-known install locations for a system browser. # Auto-detection silently bound the install to an arbitrary binary instead # of the bundled Playwright Chromium, which made the browser tool behave # differently across hosts (and, on Linux, picked up a sandboxed Snap # Chromium that hangs every browser_navigate). Every install now uses the # bundled Chromium unless the user explicitly points elsewhere. $override = $env:AGENT_BROWSER_EXECUTABLE_PATH if ([string]::IsNullOrWhiteSpace($override)) { return $null } if (Test-Path $override) { return $override } 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" Show-NpmCertHint $npmDetail | Out-Null # This install runs with --silent, so $npmDetail is often near-empty; # npm's debug log is the only place the real error survives. Write-NpmDebugLogTail -NpmOutput $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 "Explicit browser override set -- skipping bundled 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 # ============================================================================ # Resolve the PowerShell host executable used to spawn child PowerShell # processes (the astral uv installer below). We must NOT hardcode the bare # name `powershell`: it names *Windows PowerShell* and only resolves when its # System32 directory is on PATH. When install.ps1 is run under PowerShell 7+ # (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare # `powershell` spawn dies with "The term 'powershell' is not recognized", # aborting uv installation (field report: Windows install stuck, uv install # failed with exactly that message). Prefer the absolute path of the host we # are already running in (PATH-independent), then fall back to whichever of # powershell/pwsh is resolvable, and only then to the bare name. function Get-PowerShellHostExe { try { $hostExe = (Get-Process -Id $PID).Path if ($hostExe -and (Test-Path $hostExe)) { $leaf = Split-Path $hostExe -Leaf # Only trust the current host when it is a real PowerShell CLI # (not e.g. powershell_ise.exe or an embedded host that can't take # `-ExecutionPolicy`/`-Command`). if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe } } } catch { } foreach ($candidate in @("powershell", "pwsh")) { $cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($cmd -and $cmd.Source) { return $cmd.Source } } # Last-ditch: hand back the bare name so the spawn surfaces its own error. return "powershell" } function Install-Uv { # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there -- # no PATH probing, no conda guards, no multi-location resolution chains. # The runtime update path (hermes_cli/managed_uv.py) looks in the same # place, so install.ps1 and `hermes update` stay in sync. $managedUv = Join-Path $HermesHome "bin\uv.exe" if (Test-Path $managedUv) { $script:UvCmd = $managedUv $version = & $managedUv --version Write-Success "Managed uv found ($version)" return $true } Write-Info "Installing managed uv into $HermesHome\bin ..." New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null # UV_INSTALL_DIR tells the astral installer to place the binary # directly into $HermesHome\bin instead of ~/.local/bin. $prevEAP = $ErrorActionPreference try { $ErrorActionPreference = "Continue" $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" # Spawn via the resolved host exe (see Get-PowerShellHostExe) rather # than a bare `powershell`, which isn't guaranteed to be on PATH under # PowerShell 7 / pwsh-only setups. $psHostExe = Get-PowerShellHostExe & $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null $ErrorActionPreference = $prevEAP if (Test-Path $managedUv) { $script:UvCmd = $managedUv $version = & $managedUv --version Write-Success "Managed uv installed ($version)" return $true } Write-Err "uv installed but not found at $managedUv" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { if ($prevEAP) { $ErrorActionPreference = $prevEAP } Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } } # 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") } # npm lifecycle scripts on Windows spawn ``cmd.exe /d /s /c node