diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 203092f3e1..22e9f67457 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -16,10 +16,29 @@ param( [switch]$ProtocolVersion, [switch]$NonInteractive, [switch]$Json, - [switch]$IncludeDesktop + [switch]$IncludeDesktop, + # Print the paths this install would use, as JSON on stdout, 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. + # powershell -File install.ps1 -ShowResolvedPaths + [switch]$ShowResolvedPaths ) $ErrorActionPreference = "Stop" + +# --- Dot-source guard (part 1: detect) --------------------------------------- +# Tests (and any embedding host) dot-source this file (`. install.ps1`) to get +# at its FUNCTIONS. Only the definitions must enter the caller's session -- +# the install itself must never run, not even its side-effectful-looking +# prologue (the 8.3 normalization below rewrites process env vars). Dot-sourced +# files see InvocationName '.'; a real invocation sees the script +# path/expression. The flag is checked before the entry dispatch at the bottom +# (part 2), so dot-sourcing still loads every function definition. +$script:IsDotSourced = $MyInvocation.InvocationName -eq '.' +# $PSBoundParameters inside a FUNCTION refers to the function's own binding, +# so the script's binding is captured here, once, at script scope. +$script:BoundParams = $PSBoundParameters $RepoUrl = if ($env:HERMES_REPO_URL) { $env:HERMES_REPO_URL } else { "https://github.com/NousResearch/hermes-agent.git" } # --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) --- @@ -50,6 +69,259 @@ $script:GitPinFiles = @{ } # --- END GENERATED: bootstrap pins --- +# ============================================================================ +# 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, or an accented +# character. 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 once it +# reaches a provider cmdlet (Tee-Object -FilePath, Out-File, New-Item, +# Test-Path), throwing "An object at the specified path ... does not exist". +# 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. +# 2. Scripting.FileSystemObject -- fallback 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 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. + 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. + # 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. + } + } + + 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') { + $script:LastResolver = 'skipped-long-path' + 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). 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 + Write-PathDiag "expanded 8.3 short path in %$name%: $current -> $expanded" + } + } + return $rewrote +} + +# ConvertTo-LongPath only assigns $script:LastResolver when a ~\d short path +# actually needs expansion, so an ordinary long profile leaves it unset -- +# and the report below reads it unconditionally. 'none' is the resolver's own +# value for "nothing ran". +$script:LastResolver = 'none' +$script:NormalizedPathRewrites = @{} + +# (Dot-source guard, prologue side: a dot-source must not rewrite the +# caller's process env, so the normalization prologue runs only on real +# entry. Called from the entry dispatch below, before -ProtocolVersion and +# every other switch, so the resolved paths are always the install's own.) +function Initialize-ResolvedPaths { + $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. The script's own $PSBoundParameters was + # captured at script scope ($script:BoundParams) because a function body + # sees its own binding, not the script's. The re-derived paths land at + # script scope so every stage below sees them. + if ($script:BoundParams.ContainsKey('HermesHome')) { + $script:HermesHome = ConvertTo-LongPath $script:HermesHome + } else { + $script:HermesHome = ConvertTo-LongPath $( + if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" } + ) + } + if ($script:BoundParams.ContainsKey('InstallDir')) { + $script:InstallDir = ConvertTo-LongPath $script:InstallDir + } else { + $script:InstallDir = ConvertTo-LongPath $( + if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" } + ) + } + if ($script:NormalizedProfilePaths) { + Write-PathDiag "resolved install paths: HermesHome=$script:HermesHome InstallDir=$script:InstallDir" + } + + # Captured here, where the values are final. The report goes to STDOUT as + # JSON under -ShowResolvedPaths: on Windows a child's stderr does not + # reliably reach a parent process, 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 = $script:HermesHome + install_dir = $script:InstallDir + } +} + # Resolve the pm store root (same resolution as pm's store_root()): # $env:HERMES_RUNTIME_DIR wins, else \tools. function Get-PmStoreRoot { @@ -339,7 +611,8 @@ function Stage-Desktop { if (Test-Path $cand) { $desktopExe = $cand; break } } if (-not $desktopExe) { - Fail "desktop build produced no Hermes.exe under $desktopDir\release\*-unpacked\" + Fail "desktop build produced no Hermes.exe under $desktopDir +elease\*-unpacked\" } Log "Desktop ready: $desktopExe" @@ -458,8 +731,29 @@ function Invoke-StageByName([string]$name) { } } +# --- Dot-source guard (part 2: stop before entry) ---------------------------- +# Every function definition above has loaded; now stop before any real work. +if ($script:IsDotSourced) { + Write-Verbose "[hermes] install.ps1 was dot-sourced; definitions only, no execution" + return +} + +# The normalization prologue runs exactly once per real entry, before any +# switch is honored, so every contract below sees long-form paths. +Initialize-ResolvedPaths + if ($ProtocolVersion) { Write-Output 1; exit 0 } +if ($ShowResolvedPaths) { + # Side-effect-free contract: by this point every mutation the prologue + # performs (process-env 8.3 normalization) has already happened, and no + # stage, download, or write has run. This process's env is private to it, + # so the parent's environment is untouched. Stdout carries the resolved + # path report; diagnostics were suppressed by Write-PathDiag. + $script:ResolvedPathReport | ConvertTo-Json -Depth 5 -Compress | Write-Output + exit 0 +} + if ($Manifest) { @{ protocol_version = 1; stages = $Stages } | ConvertTo-Json -Depth 4 -Compress | Write-Output exit 0 diff --git a/scripts/tests/test-install-ps1-node-compatibility.ps1 b/scripts/tests/test-install-ps1-node-compatibility.ps1 index ffd7d493e1..b250f79693 100644 --- a/scripts/tests/test-install-ps1-node-compatibility.ps1 +++ b/scripts/tests/test-install-ps1-node-compatibility.ps1 @@ -1,144 +1,61 @@ -# Behavioral tests for install.ps1 system Node/npm compatibility selection. -# -# The installer is dot-sourced without running its entry point, then external -# commands and downloads are replaced with deterministic in-process stubs. -# This exercises the shipped range parser and Test-Node acceptance gate without -# changing PATH, installing software, or touching the user's Hermes home. - +# PM owns Node provisioning. This verifies the installer's delegation boundary. +$ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)) $installScript = Join-Path $repoRoot 'scripts\install.ps1' -$testRoot = Join-Path $env:TEMP ("hermes-node-compatibility-test-" + [Guid]::NewGuid().ToString('N')) -$HermesHome = Join-Path $testRoot 'home' -$InstallDir = Join-Path $testRoot 'missing-checkout' -. $installScript -HermesHome $HermesHome -InstallDir $InstallDir - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("hermes-pm-delegation-" + [Guid]::NewGuid().ToString('N')) +$testHome = Join-Path $testRoot 'home' +$checkout = Join-Path $testRoot 'checkout' $script:Failures = 0 -function Assert-Equal { - param($Expected, $Actual, [string]$Label) - if ($Expected -ceq $Actual) { - Write-Host "PASS: $Label" - } else { - Write-Host "FAIL: $Label" - Write-Host " expected: [$Expected]" - Write-Host " actual: [$Actual]" - $script:Failures++ - } +function Assert-True($Condition, [string]$Label) { + if ($Condition) { Write-Host "PASS: $Label" } + else { Write-Host "FAIL: $Label"; $script:Failures++ } } +# Tripwires exist before dot-sourcing, so a broken guard cannot run an install. +function Invoke-WebRequest { throw 'unexpected download' } +function Invoke-RestMethod { throw 'unexpected download' } +function git { throw 'unexpected git command' } +function uv { throw 'unexpected uv command' } +function node { throw 'unexpected node command' } +function npm { throw 'unexpected npm command' } -Write-Host '-- npm range evaluation --' -$supportedRange = Get-NpmRange -Assert-Equal '<11.10.0 || >=11.17.0' $supportedRange 'fresh-install fallback matches the supported npm range' -Assert-Equal $true (Test-NpmVersionOk '10.9.8') 'bundled npm 10.9.8 is accepted before clone' -Assert-Equal $true (Test-NpmVersionOk '11.9.9') 'lower alternative is accepted' -Assert-Equal $false (Test-NpmVersionOk '11.10.0') 'excluded band starts at 11.10.0' -Assert-Equal $false (Test-NpmVersionOk '11.16.0') 'reported npm 11.16.0 is rejected' -Assert-Equal $true (Test-NpmVersionOk '11.17.0') 'upper alternative starts at 11.17.0' -Assert-Equal $false (Test-NpmVersionOk 'not-a-version') 'malformed version fails closed' -Assert-Equal $false (Test-NpmVersionOk '12.0.0' '^12.0.0') 'unsupported range syntax fails closed' +try { + . $installScript -HermesHome $testHome -InstallDir $checkout + Assert-True (-not (Test-Path $testRoot)) 'dot-source loads definitions without filesystem writes' -# Controlled command surface used by the real Test-Node function. -$script:FakeNpmAvailable = $true -$script:FakeNpmVersion = '11.16.0' -$script:FakeNodeVersion = 'v24.18.0' -$script:DownloadAttempts = 0 -$script:HasNode = $null -$NodeVersion = '22' - -function node { $script:FakeNodeVersion } -function npm.cmd { $script:FakeNpmVersion } -function Get-Command { - [CmdletBinding()] - param([string]$Name) - - switch ($Name) { - 'node' { - return Microsoft.PowerShell.Core\Get-Command node -CommandType Function - } - 'npm.cmd' { - if ($script:FakeNpmAvailable) { - return Microsoft.PowerShell.Core\Get-Command npm.cmd -CommandType Function - } - return $null - } - 'npm' { return $null } - 'winget' { return $null } - default { return $null } - } -} -function Ensure-NodeExeOnPath { $true } -function Get-WindowsArch { 'x64' } -function Invoke-WebRequest { - $script:DownloadAttempts++ - throw 'network disabled by test' -} -function Write-Info { param([string]$Message) } -function Write-Warn { param([string]$Message) } -function Write-Success { param([string]$Message) } - -function Invoke-SystemNodeProbe { - param( - [string]$NodeVersion, - [string]$NpmVersion, - [bool]$NpmAvailable = $true - ) - - $script:FakeNodeVersion = $NodeVersion - $script:FakeNpmVersion = $NpmVersion - $script:FakeNpmAvailable = $NpmAvailable - $script:DownloadAttempts = 0 - $script:HasNode = $null - [void](Test-Node) - return [pscustomobject]@{ - HasNode = $script:HasNode - DownloadAttempts = $script:DownloadAttempts - } -} - -Write-Host '' -Write-Host '-- system Node acceptance --' -$result = Invoke-SystemNodeProbe 'v24.18.0' '11.17.0' -Assert-Equal $true $result.HasNode 'compatible system Node/npm is accepted' -Assert-Equal 0 $result.DownloadAttempts 'compatible system npm avoids managed download' - -$result = Invoke-SystemNodeProbe 'v22.22.0' '10.9.8' -Assert-Equal $true $result.HasNode 'minimum Node with bundled npm is accepted' -Assert-Equal 0 $result.DownloadAttempts 'bundled npm avoids managed download' - -$result = Invoke-SystemNodeProbe 'v24.18.0' '11.16.0' -Assert-Equal $false $result.HasNode 'incompatible system npm is not accepted' -Assert-Equal 1 $result.DownloadAttempts 'incompatible system npm falls through to managed Node' - -$result = Invoke-SystemNodeProbe 'v24.18.0' '' $false -Assert-Equal $false $result.HasNode 'missing system npm is not accepted' -Assert-Equal 1 $result.DownloadAttempts 'missing system npm falls through to managed Node' - -Write-Host '' -Write-Host '-- managed npm reuse --' -$managedDir = Join-Path $testRoot 'managed-node' -New-Item -ItemType Directory -Force -Path $managedDir | Out-Null -$managedNpm = Join-Path $managedDir 'npm.cmd' -@' + $fakeUv = Join-Path $testRoot 'uv.cmd' + $argsFile = Join-Path $testRoot 'args.txt' + New-Item -ItemType Directory -Force -Path (Join-Path $checkout 'pm') | Out-Null + @' @echo off -if "%~1"=="--version" ( - echo 10.9.8 - exit /b 0 -) -exit /b 42 -'@ | Set-Content -LiteralPath $managedNpm -Encoding Ascii -Assert-Equal $true (Update-ManagedNpm $managedDir) 'compatible managed npm skips the upgrade command' +echo %* > "%HERMES_TEST_UV_ARGS%" +exit /b 0 +'@ | Set-Content -LiteralPath $fakeUv -Encoding Ascii + $priorArgs = $env:HERMES_TEST_UV_ARGS + $env:HERMES_TEST_UV_ARGS = $argsFile + function Get-Uv { return $fakeUv } -if ($script:Failures -gt 0) { - Write-Host '' - Write-Host "$script:Failures assertion(s) failed" - exit 1 + $failed = $false + try { Invoke-BootstrapPm } catch { $failed = $true } + Assert-True $failed 'missing lockfile refuses delegation' + Assert-True (-not (Test-Path $argsFile)) 'missing lockfile never invokes uv' + + '{"packages":{"python":{"version":"3.13.2+test"}}}' | + Set-Content -LiteralPath (Join-Path $checkout 'pm\lock.json') -Encoding UTF8 + Invoke-BootstrapPm + $recorded = Get-Content -LiteralPath $argsFile -Raw + Assert-True ($recorded -match '--no-project') 'uv runs without ambient project discovery' + Assert-True ($recorded -match '--python 3\.13(\s|$)') 'Python minor comes from the lockfile' + Assert-True ($recorded -match 'python -m pm\.cli install') 'PM owns the install' + + function Get-Uv { throw 'node stage attempted provisioning' } + Stage-NodeDeps + Write-Host 'PASS: node stage performs no separate install' +} finally { + if (Get-Variable priorArgs -ErrorAction SilentlyContinue) { + if ($null -eq $priorArgs) { Remove-Item Env:HERMES_TEST_UV_ARGS -ErrorAction SilentlyContinue } + else { $env:HERMES_TEST_UV_ARGS = $priorArgs } + } + if (Test-Path $testRoot) { Remove-Item -LiteralPath $testRoot -Recurse -Force } } - -Write-Host '' +if ($script:Failures) { exit 1 } Write-Host 'all assertions passed' - -if (Test-Path $testRoot) { - Remove-Item -LiteralPath $testRoot -Recurse -Force -} diff --git a/tests/plugins/memory/test_hindsight_embedded_runtime.py b/tests/plugins/memory/test_hindsight_embedded_runtime.py index 6b04d04c45..d8513b9920 100644 --- a/tests/plugins/memory/test_hindsight_embedded_runtime.py +++ b/tests/plugins/memory/test_hindsight_embedded_runtime.py @@ -78,9 +78,11 @@ def test_ensure_sideenv_short_circuits_when_current(side_root, monkeypatch): gen.mkdir(parents=True) (side_root / "active.json").write_text(json.dumps({"generation": "gen-1", "pins": rt._EXPECTED}), encoding="utf-8") (gen / _STATE).write_text(json.dumps({"pins": rt._EXPECTED}), encoding="utf-8") - scripts = gen / ".venv" / "Scripts" - scripts.mkdir(parents=True) - (scripts / "python.exe").write_bytes(b"") + from hermes_constants import venv_python_path + + py = venv_python_path(gen / ".venv") + py.parent.mkdir(parents=True, exist_ok=True) + py.write_bytes(b"") monkeypatch.setattr(rt, "_uv_bridge", lambda venv: pytest.fail("bridge used for an up-to-date env")) assert rt.ensure_sideenv() == gen @@ -166,12 +168,15 @@ def loopback_health(): def _install_side_python(side_root, name="gen-1"): - """A minimal 'installed' generation: selection record + interpreter file.""" + """A minimal 'installed' generation: selection record + interpreter file + (laid out by the canonical venv_python_path, so the test matches the + platform layout the runtime itself resolves).""" + from hermes_constants import venv_python_path + gen = side_root / name - scripts = gen / ".venv" / "Scripts" - scripts.mkdir(parents=True, exist_ok=True) + py = venv_python_path(gen / ".venv") + py.parent.mkdir(parents=True, exist_ok=True) (side_root / "active.json").write_text(json.dumps({"generation": name}), encoding="utf-8") - py = scripts / "python.exe" py.write_bytes(b"") return py diff --git a/tests/pm/test_features.py b/tests/pm/test_features.py index 8c37904cca..d80c245b96 100644 --- a/tests/pm/test_features.py +++ b/tests/pm/test_features.py @@ -62,7 +62,9 @@ def test_installed_extras_reports_only_anchor_resolved(tmp_path, monkeypatch): encoding="utf-8", ) venv = tmp_path / "venv" - site = venv / "Lib" / "site-packages" + from hermes_cli.runtime_paths import site_packages + + site = site_packages(venv) site.mkdir(parents=True) (site / "somepkg.py").write_text("x = 1\n", encoding="utf-8") diff --git a/tests/pm/test_union_installs_members.py b/tests/pm/test_union_installs_members.py index 9d98c60f26..f5bf8b097f 100644 --- a/tests/pm/test_union_installs_members.py +++ b/tests/pm/test_union_installs_members.py @@ -19,6 +19,15 @@ import pytest import pm.workspace as ws +def _site_packages(venv: Path) -> Path: + """site-packages of the venv uv actually created — the running + interpreter's layout (pm pins UV_PYTHON to sys.executable), not a + per-OS hardcoded one.""" + import sysconfig + + return Path(sysconfig.get_paths(vars={"base": str(venv)})["purelib"]) + + def _uv_available() -> bool: import shutil @@ -70,10 +79,8 @@ def test_lock_and_sync_installs_member_deps(mini_workspace): _, plug, venv = mini_workspace ws.lock_and_sync([plug], [], venv_dir=venv) - site = venv / "Lib" if (venv / "Lib").exists() else venv / "lib" - packages = sorted(p.name for p in site.glob("*site-packages")) - assert packages, "no site-packages in the synced venv" - sp = site / packages[0] + sp = _site_packages(venv) + assert sp.is_dir(), "no site-packages in the synced venv" # the member's dep landed (this is the --all-packages contract) assert (sp / "pyfiglet").is_dir() or any( @@ -93,8 +100,7 @@ def test_union_survives_a_resync(mini_workspace): ws.lock_and_sync([plug], [], venv_dir=venv) ws.lock_and_sync([plug], [], venv_dir=venv) - site = venv / "Lib" if (venv / "Lib").exists() else venv / "lib" - sp = site / next(iter(sorted(p.name for p in site.glob("*site-packages")))) + sp = _site_packages(venv) assert any(p.name.startswith("pyfiglet") for p in sp.iterdir()), ( "member deps were stripped by a re-sync — the union lock must own " "them across rebuilds" diff --git a/tests/scripts/test_desktop_cli_wrapper.py b/tests/scripts/test_desktop_cli_wrapper.py index f85237910d..5f2b2bc66b 100644 --- a/tests/scripts/test_desktop_cli_wrapper.py +++ b/tests/scripts/test_desktop_cli_wrapper.py @@ -103,6 +103,7 @@ def test_configure_drops_inherited_pythonpath_and_pythonhome(tmp_path): sys.path[:] = original +@pytest.mark.platforms("windows") def test_configure_defaults_pycache_prefix_to_localappdata(tmp_path, monkeypatch): ns = _load() environ = {"LOCALAPPDATA": str(tmp_path / "lad")} @@ -118,6 +119,22 @@ def test_configure_defaults_pycache_prefix_to_localappdata(tmp_path, monkeypatch sys.pycache_prefix = original +@pytest.mark.platforms("posix") +def test_configure_defaults_pycache_prefix_to_home_cache(tmp_path, monkeypatch): + """POSIX counterpart of the LOCALAPPDATA default: ~/.cache/hermes-pycache + (same configure() contract, per the wrapper's own platform split).""" + ns = _load() + environ = {"HOME": str(tmp_path / "home")} + original = sys.pycache_prefix + try: + ns["configure"](str(tmp_path), environ=environ) + expected = os.path.join(str(tmp_path / "home"), ".cache", "hermes-pycache") + assert environ["PYTHONPYCACHEPREFIX"] == expected + assert str(sys.pycache_prefix) == expected + finally: + sys.pycache_prefix = original + + def test_configure_keeps_a_user_set_pycache_prefix(tmp_path, monkeypatch): ns = _load() environ = {"LOCALAPPDATA": str(tmp_path / "lad"), "PYTHONPYCACHEPREFIX": str(tmp_path / "mine")} diff --git a/tests/tools/test_subagent_steer.py b/tests/tools/test_subagent_steer.py index f72c6b9a32..87a7a86b8b 100644 --- a/tests/tools/test_subagent_steer.py +++ b/tests/tools/test_subagent_steer.py @@ -167,6 +167,7 @@ class TestMissedSteerRetention: parent = MagicMock() parent._delegate_depth = 0 + parent._session_db = None parent.model = "test-model" parent.interactive_mode = False @@ -204,6 +205,7 @@ class TestMissedSteerRetention: parent = MagicMock() parent._delegate_depth = 0 + parent._session_db = None parent.model = "test-model" parent.interactive_mode = False @@ -272,6 +274,7 @@ class TestMissedSteerRetention: child.steer.side_effect = steer child._drain_pending_steer.side_effect = drain parent = MagicMock() + parent._session_db = None result_box: dict = {} runner = threading.Thread( @@ -330,7 +333,12 @@ class TestMissedSteerRetention: } runner = threading.Thread( - target=lambda: _run_single_child(0, "late", child=child, parent_agent=MagicMock()) + target=lambda: _run_single_child( + 0, + "late", + child=child, + parent_agent=MagicMock(_session_db=None), + ) ) runner.start() assert callback_entered.wait(5) @@ -469,7 +477,7 @@ class TestSubagentSteerRPC: 0, "owner binding", child=child, - parent_agent=MagicMock(), + parent_agent=MagicMock(_session_db=None), owner_transport=owner_transport, owner_session_record=owner_session_record, )