Something went wrong. Try again.
Personal fork of prime agent. Mirror of: https://mnemosyne.sigilzero.dev/sigilzero/prime-agent
Something went wrong. Try again.
15 kB · 262 lines
at atom/orphans
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263# test_windows_install.ps1 — the Windows install e2e (the windows-runtime-triage# battery's install gates).## WHAT IT PROVES: both Windows entry points install the REAL release artifact# from a REAL channel shape —# 1. assemble the win32-x64 tarball with the release pipeline's own# assemble_artifacts.py (the exact artifact the channel ships),# 2. serve a local channel (the pointer, latest.json, the versioned# release prefix) over localhost,# 3. install.ps1 (the PowerShell-native route) into a scratch prefix,# 4. install-rust.sh under Git Bash (the sh route) into another prefix,# 5. assert each route's launcher answers --version and the payload# layout carries the .exe binary + the runtime sidecar + skills.## The kernel pre-warm installs uv when it is missing (the astral route; the# runner has no uv but the network is up), then bootstraps the kernel venv —# offline the step degrades to an honest note and the install still succeeds.## Usage (from the repo root, on windows-latest):# pwsh -File scripts/release/test_windows_install.ps1
$ErrorActionPreference = 'Stop'
$repo = (Get-Location).Path$scratch = Join-Path ([IO.Path]::GetTempPath()) ("prime-agent-install-e2e-{0}" -f [Guid]::NewGuid().ToString('N'))New-Item -ItemType Directory -Path $scratch | Out-Null
# The Python interpreter (the assembler + the local http server).$py = if (Get-Command python3 -ErrorAction SilentlyContinue) { 'python3' } else { 'python' }
# THE PE IMPORT WALK: the release artifact's DLL imports, read straight# from the PE headers (no dumpbin dependency on the runner).function Get-PeImportDlls { param([string]$Path) $bytes = [System.IO.File]::ReadAllBytes($Path) $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) $signature = [Text.Encoding]::ASCII.GetString($bytes, $peOffset, 4) if ($signature -ne "PE`0`0") { throw "not a PE file: $Path" } $optionalHeaderOffset = $peOffset + 24 $magic = [BitConverter]::ToUInt16($bytes, $optionalHeaderOffset) if ($magic -ne 0x20B) { throw "not a PE32+ file (magic 0x{0:X4}): $Path" -f $magic } # PE32+ optional header: the data directories start at +112; the import # table is directory 1. $importRva = [BitConverter]::ToUInt32($bytes, $optionalHeaderOffset + 112 + 8) if ($importRva -eq 0) { return @() } $numSections = [BitConverter]::ToUInt16($bytes, $peOffset + 6) $sizeOfOptionalHeader = [BitConverter]::ToUInt16($bytes, $peOffset + 20) $sectionTableOffset = $peOffset + 24 + $sizeOfOptionalHeader function ConvertTo-FileOffset([int]$Rva) { for ($i = 0; $i -lt $numSections; $i++) { $section = $sectionTableOffset + ($i * 40) $virtualAddress = [BitConverter]::ToUInt32($bytes, $section + 12) $virtualSize = [BitConverter]::ToUInt32($bytes, $section + 8) $rawPointer = [BitConverter]::ToUInt32($bytes, $section + 20) if ($Rva -ge $virtualAddress -and $Rva -lt ($virtualAddress + $virtualSize)) { return $Rva - $virtualAddress + $rawPointer } } throw "rva 0x{0:X} lies outside every section" -f $Rva } $dlls = @() $descriptor = ConvertTo-FileOffset $importRva while ($true) { $nameRva = [BitConverter]::ToUInt32($bytes, $descriptor + 12) if ($nameRva -eq 0) { break } $nameOffset = ConvertTo-FileOffset $nameRva $end = $nameOffset while ($bytes[$end] -ne 0) { $end++ } $dlls += [Text.Encoding]::ASCII.GetString($bytes, $nameOffset, $end - $nameOffset) $descriptor += 20 } return $dlls}
# 1. The release binary + the bundled catalog assets. THE SHIPPED LINK MODE# (the reviewer's finding): the release workflow builds the Windows binary# with the step-scoped crt-static RUSTFLAGS; this e2e must install the# SAME link mode - a plain dynamic build would pass the launcher smoke on# this runner image (which carries vcruntime140.dll) and mask the stock-box# 0xC0000135 regression the shipped static link exists to fix. The import# walk below proves the built binary really dropped the redist runtime DLL.$env:RUSTFLAGS = '-C target-feature=+crt-static'try { & cargo build --release --locked -p pa-cli --bin prime-agent} finally { $env:RUSTFLAGS = $null}if ($LASTEXITCODE -ne 0) { throw 'cargo build --release -p pa-cli failed' }$assets = Join-Path $scratch 'catalog-assets'& $py scripts/release/bundle_catalog.py generate --fixture --out $assetsif ($LASTEXITCODE -ne 0) { throw 'the catalog fixture generation failed' }
# 2. The channel artifact + its checksums (the assembler names the archive# by the platform alias the channel contract requires).$dist = Join-Path $scratch 'dist'$binary = Join-Path $repo 'target\release\prime-agent.exe'if (-not (Test-Path $binary)) { throw "the release binary is missing: $binary" }# The stock-box regression guard: the shipped binary must not import the# redist VC runtime (the runner has it, a stock machine does not).$importedDlls = Get-PeImportDlls -Path $binary$vcImports = @($importedDlls | Where-Object { $_ -match 'vcruntime' })if ($vcImports.Count -gt 0) { throw "the release binary imports the redist VC runtime ($($vcImports -join ', ')); the crt-static build did not take"}Write-Host "link mode: no vcruntime import ($($importedDlls.Count) DLL imports)"$version = & $binary --versionif (-not $version) { throw 'the release binary did not answer --version' }& $py scripts/release/assemble_artifacts.py --repo-root $repo --version $version --target x86_64-pc-windows-msvc --binary $binary --catalog-assets $assets --out-dir $distif ($LASTEXITCODE -ne 0) { throw 'assemble_artifacts.py failed for the msvc target' }$artifact = "prime-agent-$version-win32-x64.tar.gz"if (-not (Test-Path (Join-Path $dist $artifact))) { throw "the assembled artifact is missing: $artifact" }
# 3. The local channel: the stable pointer, the manifest with the win32-x64# row (the shape release.yml's emission publishes), the versioned prefix.$channel = Join-Path $scratch 'channel'$releaseDir = Join-Path $channel "releases\v$version"New-Item -ItemType Directory -Path $releaseDir -Force | Out-NullCopy-Item (Join-Path $dist $artifact) $releaseDirCopy-Item (Join-Path $dist 'SHA256SUMS') $releaseDirSet-Content -LiteralPath (Join-Path $channel 'stable') -Value $version -NoNewline$manifestRow = "{`"platform`": `"win32-x64`", `"file`": `"$artifact`", `"sha256`": `"$( (Get-FileHash -LiteralPath (Join-Path $releaseDir $artifact) -Algorithm SHA256).Hash.ToLower() )`"}"Set-Content -LiteralPath (Join-Path $channel 'latest.json') -Value "{`"version`": `"v$version`", `"binaries`": [$manifestRow], `"binaries_v2`": [$manifestRow]}"
# 4. Serve the channel on localhost (the installers read everything from# this one base URL).$server = Start-Process -FilePath $py -ArgumentList '-m','http.server','8123','--directory',$channel -PassThru -WindowStyle Hidden# install.ps1 writes the User PATH (the PATH-parity flow); the e2e strips# exactly its own scratch-prefixed PATH entries in the cleanup below (a# stale whole-value snapshot would linger or clobber). THE UV INSTALL IS# ISOLATED (the reviewers' finding): ownership of a file in the shared# ~/.local/bin is unprovable (a transcript line proves an attempt, not a# landing; a concurrent external install races the attribution) - so the# harness never lets the installers touch the shared dir: both routes# honor PRIME_AGENT_UV_BIN_DIR and the harness points it at its scratch.# The uv PRE-STATE still guards the parity gate below - install.ps1 skips# its uv branch when the machine already answers uv, so the gate runs# only against the fresh-runner contract.$uvWasOnPath = [bool](Get-Command uv -CommandType Application -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $HOME '.local\bin\uv.exe'))# The kernel pre-warm's writes (the venv, uv's cache, uv's pythons) and the# uv install itself would land in the real user profile; the harness# steers all of them into its own scratch dir through the product's# override knobs and restores the caller's values in the cleanup (the# discipline install.ps1 itself runs).$callerKernelVenv = $env:PRIME_AGENT_KERNEL_VENV$callerUvCacheDir = $env:UV_CACHE_DIR$callerUvPythonDir = $env:UV_PYTHON_INSTALL_DIR$callerUvBinDir = $env:PRIME_AGENT_UV_BIN_DIRtry { $env:PRIME_AGENT_KERNEL_VENV = Join-Path $scratch 'kernel-venv' $env:UV_CACHE_DIR = Join-Path $scratch 'uv-cache' $env:UV_PYTHON_INSTALL_DIR = Join-Path $scratch 'uv-python' $env:PRIME_AGENT_UV_BIN_DIR = Join-Path $scratch 'uv-bin' Start-Sleep -Seconds 2 $base = 'http://localhost:8123'
# 5. Route A: install.ps1 (the PowerShell-native entry point). The local # channel rides the explicitly-named plaintext knob (the installers' # https rule has exactly this escape hatch; a real channel is always # https). $prefixA = Join-Path $scratch 'prefix-a' New-Item -ItemType Directory -Path $prefixA | Out-Null $env:PRIME_AGENT_DOWNLOAD_BASE_URL = $base $env:PRIME_AGENT_RELEASE_CHANNEL = 'stable' $env:PRIME_AGENT_RUST_PREFIX = $prefixA $env:PRIME_AGENT_ALLOW_HTTP = '1' # The transcript is captured (the fallback harness's pattern): it is the # uv gate's evidence below - the branch's own verdict, not the network's. $ps1Lines = @(& pwsh -File (Join-Path $repo 'install.ps1') 2>&1 | ForEach-Object { "$_" }) if ($LASTEXITCODE -ne 0) { $ps1Lines | Write-Host; throw 'install.ps1 failed' } $ps1Transcript = $ps1Lines -join [Environment]::NewLine $cmdLauncher = Join-Path $prefixA 'bin\prime-agent.cmd' if (-not (Test-Path $cmdLauncher)) { throw "the cmd launcher is missing: $cmdLauncher" } $versionA = (& $cmdLauncher --version) | Select-Object -First 1 if ($versionA -ne $version) { throw "the cmd launcher answered '$versionA' instead of '$version'" } foreach ($entry in @('share\prime-agent\prime-agent.exe', 'share\prime-agent\prime-agent-runtime\pyproject.toml', 'share\prime-agent\LICENSE', 'share\prime-agent\skills')) { if (-not (Test-Path (Join-Path $prefixA $entry))) { throw "the ps1 route's payload is missing $entry" } } # The uv parity gate asserts the CONTRACT, never astral.sh availability # (the reviewer's finding): the branch is best-effort by design - offline # it degrades to the honest note and the install still succeeds - so # the gate demands either the landed binary (the online path) or the # honest degradation in the transcript. A branch that lands NEITHER is # broken; the network being down is not. $uvExePath = Join-Path (Join-Path $scratch 'uv-bin') 'uv.exe' $uvLanded = Test-Path $uvExePath $degradedHonestly = $ps1Transcript -match [regex]::Escape('could not install uv; the kernel pre-warm was skipped') if (-not $uvWasOnPath -and -not $uvLanded -and -not $degradedHonestly) { throw "install.ps1's uv branch landed no uv.exe and no honest degradation note (the branch is broken, not the network)" }
# 6. Route B: install-rust.sh under Git Bash (the sh entry point). $prefixB = Join-Path $scratch 'prefix-b' New-Item -ItemType Directory -Path $prefixB | Out-Null $bash = 'C:\Program Files\Git\bin\bash.exe' if (-not (Test-Path $bash)) { throw "Git Bash is required for the sh route: $bash" } $env:PRIME_AGENT_DOWNLOAD_BASE_URL = $base $env:PRIME_AGENT_RELEASE_CHANNEL = 'stable' $env:PRIME_AGENT_RUST_PREFIX = $prefixB $env:PRIME_AGENT_ALLOW_HTTP = '1' & $bash (Join-Path $repo 'install-rust.sh') if ($LASTEXITCODE -ne 0) { throw 'install-rust.sh failed under Git Bash' } $shLauncher = Join-Path $prefixB 'bin\prime-agent' if (-not (Test-Path $shLauncher)) { throw "the sh launcher is missing: $shLauncher" } $cmdLauncherB = Join-Path $prefixB 'bin\prime-agent.cmd' if (-not (Test-Path $cmdLauncherB)) { throw "the sh route must also write the cmd twin: $cmdLauncherB" } # The MSYS form of the prefix (bash cannot parse the drive-letter spelling # inside its own quoting). $prefixBMsys = (& $bash -c ("cygpath -u '" + $prefixB + "'")).Trim() $versionB = (& $bash -c ('"' + $prefixBMsys + '/bin/prime-agent" --version') | Select-Object -First 1) if ($versionB -ne $version) { throw "the sh launcher answered '$versionB' instead of '$version'" } foreach ($entry in @('share\prime-agent\prime-agent.exe', 'share\prime-agent\prime-agent-runtime\pyproject.toml')) { if (-not (Test-Path (Join-Path $prefixB $entry))) { throw "the sh route's payload is missing $entry" } }
Write-Host "WIN_INSTALL_E2E ps1=$versionA sh=${versionB}: both routes installed the ${version} win32-x64 payload and answered --version"} finally { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue # The User PATH: strip ONLY the entries this test added - the ones # under its own scratch dir - from the CURRENT registry value, never # a stale snapshot restore (a whole-value overwrite would clobber any # external PATH change made while the test ran) and never through # [Environment]::SetEnvironmentVariable (it flattens a REG_EXPAND_SZ # Path to plain REG_SZ with this run's expansion frozen in). The raw # value rides out with its registry kind intact; a Path this test # created from nothing is deleted again; a Path it never touched is # not rewritten at all. A cleanup failure is recorded and the # remaining steps still run (the loud tail below reports it). $pathCleanFailed = $false $envKey = $null try { $envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) $rawUserPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $rawUserKind = [Microsoft.Win32.RegistryValueKind]::ExpandString if ($envKey.GetValueNames() -contains 'Path') { $rawUserKind = $envKey.GetValueKind('Path') } $allEntries = @($rawUserPath -split ';') $keptEntries = @($allEntries | Where-Object { -not $_.Trim().StartsWith($scratch, [System.StringComparison]::OrdinalIgnoreCase) }) if ($keptEntries.Count -lt $allEntries.Count) { if ($keptEntries.Count -gt 0) { $envKey.SetValue('Path', ($keptEntries -join ';'), $rawUserKind) } else { $envKey.DeleteValue('Path', $false) } } } catch { $pathCleanFailed = $true } finally { if ($envKey) { $envKey.Close() } } # The uv install needs no cleanup: it never left the scratch dir (the # PRIME_AGENT_UV_BIN_DIR knob above steered both installers), and the # user's ~/.local/bin was never touched at all. $env:PRIME_AGENT_KERNEL_VENV = $callerKernelVenv $env:UV_CACHE_DIR = $callerUvCacheDir $env:UV_PYTHON_INSTALL_DIR = $callerUvPythonDir $env:PRIME_AGENT_UV_BIN_DIR = $callerUvBinDir Remove-Item -Recurse -Force $scratch -ErrorAction SilentlyContinue if ($pathCleanFailed) { throw 'could not clean the user PATH entries this test added' }}