From eeb3da55dafbfc026cc6a16e71e2c9fb35dbdbc7 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sun, 6 Sep 2026 13:42:36 -0400 Subject: [PATCH] fix(install): restore safe path probes and portable fixtures Normalize 8.3 paths before stage dispatch and support read-only resolved-path output. Dot-sourcing loads definitions without running the installer. Test the PM delegation contract instead of restoring the removed Node installer. Use native environment layouts in PM and Hindsight tests. Give steering-test parents no database so child construction cannot create SQLite files at mock paths. Parent verification passed 145 Python tests with one skip and all native PowerShell path, delegation, and stage checks. --- scripts/install.ps1 | 298 +++++++++++++++++- .../test-install-ps1-node-compatibility.ps1 | 185 +++-------- .../memory/test_hindsight_embedded_runtime.py | 19 +- tests/pm/test_features.py | 4 +- tests/pm/test_union_installs_members.py | 18 +- tests/scripts/test_desktop_cli_wrapper.py | 17 + tests/tools/test_subagent_steer.py | 12 +- 7 files changed, 401 insertions(+), 152 deletions(-) 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, )