Merge pull request #78682 from NousResearch/bb/win-8dot3-profile-paths

fix(install): Windows install completes on profiles with a space, dot, or accent in the username
This commit is contained in:
brooklyn!
2026-08-05 08:21:23 -06:00
committed by GitHub
7 changed files with 618 additions and 73 deletions

View File

@@ -36,6 +36,9 @@ outputs:
npm_lock:
description: Post/update the semantic package-lock.json diff PR comment.
value: ${{ steps.classify.outputs.npm_lock }}
installer:
description: Run the PowerShell installer tests on a Windows runner.
value: ${{ steps.classify.outputs.installer }}
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}

View File

@@ -48,6 +48,7 @@ jobs:
scan: ${{ steps.classify.outputs.scan }}
deps: ${{ steps.classify.outputs.deps }}
npm_lock: ${{ steps.classify.outputs.npm_lock }}
installer: ${{ steps.classify.outputs.installer }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
@@ -87,6 +88,13 @@ jobs:
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
installer-tests:
name: Installer tests
needs: detect
# Windows-only, and only for PRs that touch install.ps1 or its tests.
if: needs.detect.outputs.installer == 'true'
uses: ./.github/workflows/installer-tests.yml
e2e-desktop:
name: Desktop E2E
needs: detect
@@ -275,6 +283,7 @@ jobs:
- tests
- lint
- js-tests
- installer-tests
- e2e-desktop
- docs-site
- history-check

38
.github/workflows/installer-tests.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Installer tests
# scripts/install.ps1's PowerShell tests. They exercise the installer as a real
# subprocess, and every path contract they assert (8.3 short-name aliases,
# Git Bash layouts, provider-cmdlet behavior) is Windows-specific — so they need
# a Windows runner. Before this workflow existed the files were in the tree but
# nothing ever ran them.
on:
workflow_call:
permissions:
contents: read
concurrency:
group: installer-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
powershell:
name: PowerShell installer tests
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Windows PowerShell 5.1 as well as pwsh 7: install.ps1 is delivered via
# `irm | iex` into whatever shell the user already has, and 5.1 is what
# ships with Windows. A construct that only parses under 7 is a broken
# installer for most of the people hitting it.
- name: 8.3 short-path normalization (pwsh 7)
shell: pwsh
run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
- name: 8.3 short-path normalization (Windows PowerShell 5.1)
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1

View File

@@ -19,6 +19,7 @@ Lanes:
* ``scan`` — supply-chain scan (Python files, .pth, setup hooks).
* ``deps`` — pyproject.toml dependency bounds check.
* ``npm_lock`` — semantic package-lock.json diff PR comment.
* ``installer`` — PowerShell installer tests (Windows runner).
* ``mcp_catalog`` — bundled MCP catalog / installer review.
Docker is not a lane — it builds on push-to-main and release only,
@@ -68,6 +69,11 @@ _SCAN_FILES = {"setup.cfg", "pyproject.toml"}
_MCP_CATALOG_PATHS = ("optional-mcps/",)
_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
# Windows installer + its PowerShell tests. These only run on a Windows runner,
# so they get their own lane rather than riding along with ``python``.
_INSTALLER_PATHS = ("scripts/tests/",)
_INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"}
def _is_docs(p: str) -> bool:
if p.startswith(("skills/", "optional-skills/")):
return False
@@ -98,6 +104,10 @@ def _is_mcp_catalog(p: str) -> bool:
return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES
def _is_installer(p: str) -> bool:
return p.startswith(_INSTALLER_PATHS) or p in _INSTALLER_FILES
def _is_ci_review(p: str) -> bool:
if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS):
return True
@@ -123,6 +133,7 @@ def classify(files: list[str]) -> dict[str, bool]:
"scan": any(_is_scan(f) for f in files),
"deps": any(f == "pyproject.toml" for f in files),
"npm_lock": any(f.split("/")[-1] == "package-lock.json" for f in files),
"installer": any(_is_installer(f) for f in files),
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
"ci_review": any(_is_ci_review(f) for f in files),
}
@@ -135,6 +146,7 @@ def classify(files: list[str]) -> dict[str, bool]:
ret["scan"] = True
ret["deps"] = True
ret["npm_lock"] = True
ret["installer"] = True
ret["ci_review"] = True
# explicitly skip mcp catalog here. it's not needed unless those files are modified.

View File

@@ -43,6 +43,15 @@ param(
[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,
@@ -97,45 +106,267 @@ try {
# ============================================================================
# 8.3 short-path normalization
# ============================================================================
# When the Windows user-profile folder name contains a space (e.g.
# "First Last"), Windows generates an 8.3 short alias for it (e.g. FIRST~1.LAS)
# and may expose %TEMP%/%TMP% in that short form:
# 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 "~1.ext" component when such a
# path is handed to a provider cmdlet like `Tee-Object -FilePath` /
# `Out-File -FilePath`, throwing:
# "An object at the specified path C:\Users\FIRST~1.LAS does not exist."
# Every Node/Electron build+install stage streams its log to %TEMP% via
# Tee-Object, so they all abort with that error, while the Python/uv stages --
# which never write a side log to %TEMP% through a provider cmdlet -- complete
# fine. Expanding %TEMP%/%TMP% back to their long form once, up front, lets
# every downstream cmdlet (and child process) see a path the provider can
# resolve. (GH: Windows desktop installer fails at Node/Electron stages.)
#
# 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.
function ConvertTo-LongPath {
$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)
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
# Only 8.3 short names carry a tilde+digit ("~1"); skip the COM round-trip
# for ordinary long paths.
if ($Path -notmatch '~\d') { return $Path }
try {
$fso = New-Object -ComObject Scripting.FileSystemObject
if ($fso.FolderExists($Path)) { return $fso.GetFolder($Path).Path }
if ($fso.FileExists($Path)) { return $fso.GetFile($Path).Path }
} catch {
# COM unavailable / locked-down host: fall back to the original 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
}
foreach ($tmpVar in @('TEMP', 'TMP')) {
$current = [Environment]::GetEnvironmentVariable($tmpVar)
if ($current) {
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:$tmpVar" -Value $expanded
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
}
# ============================================================================
@@ -3233,7 +3464,7 @@ function Install-Desktop {
throw "apps/desktop build failed (exit $code)"
}
Write-Success "Desktop app built"
Remove-Item -Force $buildLog -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $buildLog -Force -ErrorAction SilentlyContinue
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
@@ -3955,6 +4186,11 @@ try {
exit 0
}
if ($ShowResolvedPaths) {
$script:ResolvedPathReport | ConvertTo-Json -Depth 5 -Compress | Write-Output
exit 0
}
if ($Manifest) {
$payload = @{
protocol_version = $InstallStageProtocolVersion

View File

@@ -1,21 +1,36 @@
# Unit tests for install.ps1's ConvertTo-LongPath helper.
# Tests for install.ps1's 8.3 short-path normalization.
#
# Run from a PowerShell prompt:
#
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
# pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
#
# Background: on a Windows profile whose folder name contains a space (e.g.
# "First Last"), %TEMP%/%TMP% can be exposed as an 8.3 short path
# Background: when the Windows profile folder's name contains a space
# ("First Last"), a dot ("Stone.ZEN8"), or an accented character, Windows can
# expose %TEMP%, %LOCALAPPDATA% and friends as an 8.3 alias
# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the
# "~1.ext" component when it reaches a provider cmdlet (Tee-Object -FilePath),
# aborting the Node/Electron install+build stages. install.ps1 expands such
# paths to their long form up front; this verifies the helper's contract.
# aliased component once it reaches a provider cmdlet (Tee-Object -FilePath),
# aborting the Node/Electron stages and the desktop post-build probe.
# install.ps1 expands those paths up front; this asserts that contract.
#
# We extract just the function from install.ps1 via the AST so the installer's
# top-level body never runs (dot-sourcing would execute the whole script).
# The COM-backed expansion only fires for inputs containing "~<digit>"; the
# pass-through and graceful-fallback paths are assertable on any host (incl.
# non-Windows pwsh, where the COM object is simply unavailable).
# HOW THIS RUNS THE CODE: by executing install.ps1 as a real subprocess with a
# crafted environment and reading what it reports back. `-ProtocolVersion` is a
# side-effect-free early exit that sits BELOW the normalization block, so the
# whole block -- including the script-level Add-Type the kernel32 resolver
# needs -- executes exactly as it does during an install. Nothing here parses
# install.ps1's source (AGENTS.md bans source-reading tests: they pass on
# broken code and fail on correct refactors).
#
# HERMETIC ENVIRONMENT: every case sets all five profile variables explicitly.
# GitHub's own Windows runners hand down a genuinely 8.3-aliased TEMP/TMP
# (C:\Users\RUNNER~1\AppData\Local\Temp), so an inherited variable is a live
# instance of the very bug under test and would contaminate any case that
# didn't override it.
#
# Portability: resolver 3 (profile-root substitution) is pure path arithmetic,
# so the substitution assertions run everywhere, including non-Windows CI. The
# kernel32 and COM resolvers only have anything to expand on a real Windows
# volume; on other hosts they no-op and fall through, which is itself the
# graceful-degradation contract asserted below.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
@@ -26,61 +41,283 @@ if (-not (Test-Path $installScript)) {
}
$failures = 0
$script:lastRaw = ''
function Assert-Equal {
param([Parameter(Mandatory = $true)] $Expected,
[Parameter(Mandatory = $true)] $Actual,
[Parameter(Mandatory = $true)] [string]$Label)
param($Expected, $Actual, [Parameter(Mandatory = $true)][string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
if ($script:lastRaw) {
# The installer's own account of what it did, plus the environment
# it was handed. Without both, a failure on a host you cannot reach
# is pure guesswork.
Write-Host " installer reported: $script:lastRaw"
Write-Host " environment sent: $script:lastEnv"
}
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
# --- Load ConvertTo-LongPath from install.ps1 without executing the script ---
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($installScript, [ref]$tokens, [ref]$errors)
$fnAst = $ast.FindAll(
{
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'ConvertTo-LongPath'
}, $true) | Select-Object -First 1
# --- Harness ---------------------------------------------------------------
# The real profile root the installer will substitute in, derived the same way
# install.ps1 derives it so these assertions hold on any host and any account.
$profileDir = [Environment]::GetFolderPath('UserProfile')
$usersDir = Split-Path -Parent $profileDir
$sep = [System.IO.Path]::DirectorySeparatorChar
if (-not $fnAst) {
throw "ConvertTo-LongPath not found in install.ps1 -- did the helper get renamed/removed?"
# Starting guess for the baseline environment; replaced by the probe below with
# whatever root the installer itself resolves.
$script:baseRoot = $profileDir
# A profile alias that cannot resolve: no such folder exists, so kernel32 and
# COM both fail and only the profile-root substitution can handle it.
$shortProfile = Join-Path $usersDir 'FIRST~1.LAS'
function Join-Parts {
# Join path segments with the platform separator. Literal forward slashes
# inside a path would make Split-Path's behavior host-dependent, which is
# noise this suite doesn't need.
param([string[]]$Parts)
return ($Parts -join $sep)
}
. ([scriptblock]::Create($fnAst.Extent.Text))
# --- Tests ---
# Ask install.ps1 what paths it resolves under a given environment.
#
# -ShowResolvedPaths prints a JSON object on STDOUT and exits without touching
# anything, so the whole normalization block -- including the script-level
# Add-Type the kernel32 resolver needs -- has already run by the time it is
# printed. Stdout, deliberately: three separate stderr capture mechanisms
# (ProcessStartInfo.RedirectStandardError, `2>$file`, and a merged `2>&1`
# pipeline) were each verified to come back EMPTY from the installer on a
# windows-latest runner while stdout arrived intact. The installer's human
# diagnostics still go to stderr; the machine-readable contract is on stdout,
# which is the only stream that survives everywhere.
#
# Environment overrides are applied to this process and restored afterwards,
# since that is what the child inherits.
function Invoke-Normalization {
param(
[hashtable]$Environment = @{},
[string[]]$ExtraArgs = @()
)
# Start from a long, self-consistent profile so nothing is inherited;
# callers override only the variables their case is about. $script:baseRoot
# is the test's best guess until the probe below replaces it with the root
# the installer actually resolves.
$root = $script:baseRoot
$env0 = @{
TEMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
TMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
LOCALAPPDATA = (Join-Parts @($root, 'AppData', 'Local'))
APPDATA = (Join-Parts @($root, 'AppData', 'Roaming'))
USERPROFILE = $root
HERMES_HOME = ''
}
foreach ($key in $Environment.Keys) { $env0[$key] = $Environment[$key] }
$psExe = (Get-Process -Id $PID).Path
$outFile = [System.IO.Path]::GetTempFileName()
$errFile = [System.IO.Path]::GetTempFileName()
$saved = @{}
foreach ($key in $env0.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) }
try {
foreach ($key in $env0.Keys) { Set-Item -Path "Env:$key" -Value $env0[$key] }
$callArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installScript) + $ExtraArgs + @('-ShowResolvedPaths')
# The call operator, not Start-Process: on Windows Start-Process does
# not hand the parent's modified environment block to the child, so the
# installer saw the runner's real TEMP instead of the aliased one this
# case sets, and every rewrite assertion came back "not rewritten".
# `&` inherits the environment on every host.
#
# stderr is merged into the same file rather than redirected separately:
# Windows PowerShell 5.1 wraps ANY stderr from a native command in a
# NativeCommandError record, and a bare `2>$file` still emits that
# record into this script's error stream, which fails the 5.1 lane even
# under 'Continue'. Merging with 2>&1 keeps the bytes and produces no
# error record. The installer's stdout here is a single JSON object and
# its diagnostics are all `[hermes] `-prefixed, so the two separate
# cleanly on the way back out.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$global:LASTEXITCODE = 0
try {
& $psExe @callArgs *> $outFile
} finally {
$ErrorActionPreference = $prevEAP
}
$exitCode = $LASTEXITCODE
$raw = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue)
$stderr = ($raw | Where-Object { $_ -like '`[hermes`]*' }) -join "`n"
$stdout = ($raw | Where-Object { $_ -notlike '`[hermes`]*' }) -join "`n"
} finally {
foreach ($key in $saved.Keys) {
if ($null -eq $saved[$key]) {
Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue
} else {
Set-Item -Path "Env:$key" -Value $saved[$key]
}
}
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
if ($null -eq $stdout) { $stdout = '' }
$stdout = $stdout.Trim()
$script:lastRaw = if ($stdout) { $stdout } else { '(child produced no stdout)' }
$script:lastEnv = ($env0.Keys | Sort-Object | ForEach-Object { "$_=$($env0[$_])" }) -join '; '
$paths = $null
if ($stdout) {
try { $paths = $stdout | ConvertFrom-Json } catch { $paths = $null }
}
# normalized is an object keyed by variable name; flatten to a hashtable so
# callers can ask "was TEMP rewritten, and to what".
$rewrites = @{}
if ($paths -and $paths.normalized) {
foreach ($prop in $paths.normalized.PSObject.Properties) {
$rewrites[$prop.Name] = "$($prop.Value)"
}
}
return @{
ExitCode = $exitCode
Stdout = $stdout
Rewrites = $rewrites
InstallDir = $(if ($paths) { $paths.install_dir } else { $null })
HermesHome = $(if ($paths) { $paths.hermes_home } else { $null })
LongRoot = $(if ($paths) { $paths.long_profile_root } else { $null })
}
}
function Get-Rewrite {
# '' rather than $null for an untouched variable, so a failure prints
# something legible instead of a blank.
param($Result, [string]$Name)
if ($Result.Rewrites.ContainsKey($Name)) { return $Result.Rewrites[$Name] }
return '<not rewritten>'
}
# Ask the installer once, up front, which long root it resolves on this host,
# and assert every expectation against that. Deriving it independently in the
# test would only prove the two derivations agree, not that the fix works --
# and on GitHub's Windows runners they don't agree, because the runner hands
# down a genuinely 8.3-aliased profile.
$probe = Invoke-Normalization @{ USERPROFILE = $shortProfile }
$longRoot = $probe.LongRoot
Write-Host ""
Write-Host "-- ConvertTo-LongPath --"
Write-Host "-- the installer resolves a long profile root --"
if ([string]::IsNullOrEmpty($longRoot)) {
# Nothing below can mean anything without this, so show the child's whole
# output rather than leaving a bare assertion failure on an unreachable host.
Write-Host "FAIL: a long profile root is found" -ForegroundColor Red
Write-Host " probe exit code: $($probe.ExitCode)"
Write-Host " probe stdout: $($probe.Stdout)"
Write-Host " probe env: $script:lastEnv"
Write-Host " probe stdout (raw):"
foreach ($line in ($script:lastRaw -split "`r?`n")) {
if ($line.Trim()) { Write-Host " $line" }
}
Write-Host "FAILED: cannot continue without a long profile root" -ForegroundColor Red
exit 1
}
Write-Host "OK: a long profile root is found ($longRoot)" -ForegroundColor Green
Assert-Equal -Expected $false -Actual ($longRoot -match '~\d') -Label "the resolved root carries no 8.3 alias"
# Every subsequent case's baseline is now the installer's own root, so a
# "nothing to expand" case really has nothing to expand even on a runner whose
# inherited profile is itself aliased.
$script:baseRoot = $longRoot
Assert-Equal -Expected "" -Actual (ConvertTo-LongPath "") -Label "empty string returns empty"
Assert-Equal -Expected $null -Actual (ConvertTo-LongPath $null) -Label "null returns null"
Write-Host ""
Write-Host "-- normalization is a no-op for ordinary paths --"
# No 8.3 component -> returned verbatim (even with spaces).
$longish = "C:\Users\First Last\AppData\Local\Temp"
Assert-Equal -Expected $longish -Actual (ConvertTo-LongPath $longish) -Label "long path with spaces is unchanged"
# A profile name with a space is NOT itself a short path; nothing to expand.
$result = Invoke-Normalization
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "long paths: install.ps1 still reaches its early exit"
Assert-Equal -Expected 0 -Actual $result.Rewrites.Count -Label "long paths: nothing rewritten"
Assert-Equal -Expected $false -Actual ($result.InstallDir -match '~\d') -Label "long paths: InstallDir passes through clean"
$noTilde = "/tmp/some/long/path"
Assert-Equal -Expected $noTilde -Actual (ConvertTo-LongPath $noTilde) -Label "tilde-free path is unchanged"
Write-Host ""
Write-Host "-- an unresolvable profile alias is rebuilt on the long profile root --"
# Looks like an 8.3 name but does not exist -> graceful fallback to the input
# (FolderExists/FileExists both false, or COM unavailable on this host).
$fakeShort = "C:\Users\FIRST~1.LAS\does\not\exist"
Assert-Equal -Expected $fakeShort -Actual (ConvertTo-LongPath $fakeShort) -Label "nonexistent 8.3 path falls back to input"
# The reported failure: TEMP under an 8.3 profile alias that no resolver can
# expand (8dot3 disabled, or a stale alias). GH #52842, GH #57526.
$shortTemp = Join-Parts @($shortProfile, 'AppData', 'Local', 'Temp')
$expectedTemp = Join-Parts @($profileDir, 'AppData', 'Local', 'Temp')
# --- Summary ---
$result = Invoke-Normalization @{ TEMP = $shortTemp; TMP = $shortTemp }
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "short TEMP: install.ps1 still reaches its early exit"
$expectedTemp = "$longRoot${sep}AppData${sep}Local${sep}Temp"
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP is rebuilt on the long profile root"
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TMP') -Label "short TMP is rebuilt on the long profile root"
Assert-Equal -Expected 2 -Actual $result.Rewrites.Count -Label "short TEMP: only the aliased variables are touched"
# The profile root itself, with no tail to reattach. USERPROFILE is also where
# the installer looks first for a long root, so this exercises the fallback to
# HOMEDRIVE/HOMEPATH and %USERNAME%.
$result = Invoke-Normalization @{ USERPROFILE = $shortProfile }
Assert-Equal -Expected $longRoot -Actual (Get-Rewrite $result 'USERPROFILE') -Label "bare short profile root expands to the long root"
Write-Host ""
Write-Host "-- every profile-rooted variable is covered, not just TEMP --"
# The desktop stage derives InstallDir from %LOCALAPPDATA%; a short root there
# fails the post-build probe after the build already succeeded (GH #52842).
$result = Invoke-Normalization @{
TEMP = $shortTemp
TMP = $shortTemp
LOCALAPPDATA = (Join-Parts @($shortProfile, 'AppData', 'Local'))
APPDATA = (Join-Parts @($shortProfile, 'AppData', 'Roaming'))
USERPROFILE = $shortProfile
}
foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) {
$value = Get-Rewrite $result $name
# Assert it was rewritten AND that the result is clean. Checking only for
# the absence of a tilde passes vacuously on a variable nothing touched.
Assert-Equal -Expected $true -Actual ($value.StartsWith($longRoot)) -Label "$name is rebuilt on the long profile root"
Assert-Equal -Expected $false -Actual ($value -match '~\d') -Label "$name no longer carries an 8.3 alias"
}
# ...and the install paths derived from them are re-derived, not left short.
# This is the difference between "the build works" and "the installer stops
# claiming a successful build failed". Composed with literal backslashes
# because that is how install.ps1 itself builds the default Windows path.
$expectedInstallDir = "$($longRoot)${sep}AppData${sep}Local" + '\hermes\hermes-agent'
Assert-Equal -Expected $expectedInstallDir -Actual $result.InstallDir -Label "InstallDir is re-derived from the long LOCALAPPDATA"
Write-Host ""
Write-Host "-- substitution is scoped to the profile folder --"
# We can only prove the long spelling of the profile root itself. A short
# component anywhere else must be left exactly as the caller set it.
$belowProfile = Join-Parts @($profileDir, 'DEEPLY~1', 'Temp')
$result = Invoke-Normalization @{ TEMP = $belowProfile; TMP = $belowProfile }
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "a short component below the profile root is left alone"
# A custom TEMP on another volume has no profile root to substitute.
$otherVolume = Join-Parts @("D:", 'SHORT~1', 'Temp')
$result = Invoke-Normalization @{ TEMP = $otherVolume; TMP = $otherVolume }
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP outside the profile is left alone"
Write-Host ""
Write-Host "-- an explicit -InstallDir is normalized, never replaced --"
$result = Invoke-Normalization -Environment @{ TEMP = $shortTemp; TMP = $shortTemp } `
-ExtraArgs @('-InstallDir', (Join-Path $shortProfile 'custom-hermes'))
Assert-Equal -Expected (Join-Path $longRoot 'custom-hermes') -Actual $result.InstallDir -Label "explicit -InstallDir keeps the caller's directory, on the long root"
# --- Summary ---------------------------------------------------------------
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
} else {
Write-Host "All ConvertTo-LongPath tests passed." -ForegroundColor Green
Write-Host "All 8.3 short-path normalization tests passed." -ForegroundColor Green
exit 0
}

View File

@@ -30,12 +30,13 @@ DEFAULT = {
"scan": True,
"deps": True,
"npm_lock": True,
"installer": True,
"mcp_catalog": False,
"ci_review": True,
}
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]:
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm_lock=False, installer=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None) -> dict[str, bool]:
# python_prod tracks python except for tests-only diffs; default it to
# python so the majority of cases don't need to spell it out.
return {
@@ -47,6 +48,7 @@ def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, npm
"scan": scan,
"deps": deps,
"npm_lock": npm_lock,
"installer": installer,
"mcp_catalog": mcp_catalog,
"ci_review": ci_review,
}
@@ -67,6 +69,14 @@ CASES = {
# skill edit must still run Python.
"skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),
"dockerfile → docker meta": (["Dockerfile"], _lanes(docker_meta=True)),
# install.ps1 is a shell script Python never imports, but it's also not
# provably prose, so python stays on (fail-open) alongside the Windows lane.
"install.ps1 → installer": (["scripts/install.ps1"], _lanes(python=True, installer=True)),
"installer test → installer": (
["scripts/tests/test-install-ps1-longpath.ps1"],
_lanes(python=True, installer=True),
),
"python source alone → no installer lane": (["run_agent.py"], _lanes(python=True, scan=True)),
# Unknown top-level file keeps Python on rather than risk a silent skip.
"unknown toplevel → python": (["Makefile"], _lanes(python=True)),
"mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True, scan=True)),