Files
waggle-os/scripts/certify-windows-installer.ps1
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

3423 lines
148 KiB
PowerShell

#Requires -Version 7.0
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$InstallerPath,
[ValidateRange(30, 600)]
[int]$StartupTimeoutSeconds = 150,
[string]$ReceiptPath,
[switch]$KeepArtifacts,
[switch]$RequireAuthenticodeSignature,
[string]$ExpectedSignerThumbprint,
[string]$ExpectedSignerSubject,
[string]$ExpectedSourceRevision,
[int]$WebViewDebugPort = 0,
[switch]$VerifyManagedModel,
[switch]$RequireVersionToVersionUpgrade,
[string]$PreviousInstallerPath,
[string]$ExpectedPreviousInstallerSha256,
[string]$ExpectedPreviousVersion,
[string]$ExpectedPreviousSourceRevision,
[string]$ExpectedCandidateInstallerSha256,
[string]$ExpectedCandidateVersion
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-True {
param(
[Parameter(Mandatory = $true)] [bool]$Condition,
[Parameter(Mandatory = $true)] [string]$Message
)
if (-not $Condition) { throw $Message }
}
function Assert-CleanRepositoryWorktree {
param(
[Parameter(Mandatory = $true)] [string]$GitExecutable,
[Parameter(Mandatory = $true)] [string]$RepositoryRoot
)
$sourceStatus = @(
& $GitExecutable -C $RepositoryRoot status --porcelain=v1 --untracked-files=all
)
if ($LASTEXITCODE -ne 0) {
throw 'Could not inspect the repository source state.'
}
if ($sourceStatus.Count -ne 0) {
$sourceDetails = ($sourceStatus | ForEach-Object { [string]$_ }) -join [Environment]::NewLine
throw "Installer certification requires the complete repository worktree to match the expected revision.$([Environment]::NewLine)$sourceDetails"
}
}
function Test-CertificateTimestamp {
param([AllowNull()] [object]$Value)
if ($null -eq $Value) { return $false }
if ($Value -is [DateTime] -or $Value -is [DateTimeOffset]) { return $true }
$text = [string]$Value
if ([string]::IsNullOrWhiteSpace($text)) { return $false }
$parsed = [DateTimeOffset]::MinValue
return [DateTimeOffset]::TryParse(
$text,
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::RoundtripKind,
[ref]$parsed
)
}
function Assert-ExpectedAuthenticodeSignature {
param(
[Parameter(Mandatory = $true)] [object]$Signature,
[AllowEmptyString()] [string]$ExpectedThumbprint,
[AllowEmptyString()] [string]$ExpectedSubject,
[Parameter(Mandatory = $true)] [string]$ArtifactLabel
)
$hasExpectedThumbprint = -not [string]::IsNullOrWhiteSpace($ExpectedThumbprint)
$hasExpectedSubject = -not [string]::IsNullOrWhiteSpace($ExpectedSubject)
Assert-True ($hasExpectedThumbprint -xor $hasExpectedSubject) `
'Exactly one expected signer identity binding is required.'
Assert-True ([string]$Signature.SignatureType -eq 'Authenticode') `
"$ArtifactLabel does not contain a portable embedded Authenticode signature."
Assert-True ($Signature.Status -eq [System.Management.Automation.SignatureStatus]::Valid) `
"$ArtifactLabel Authenticode signature is not valid: $($Signature.Status)"
Assert-True ($null -ne $Signature.SignerCertificate) `
"$ArtifactLabel has no Authenticode signer certificate."
if ($hasExpectedThumbprint) {
$normalizedExpectedThumbprint = ($ExpectedThumbprint -replace '\s', '').ToUpperInvariant()
Assert-True ($normalizedExpectedThumbprint -match '^[0-9A-F]{40}$') `
'Expected signer thumbprint must be exactly 40 hexadecimal characters.'
Assert-True (
[string]::Equals(
($Signature.SignerCertificate.Thumbprint -replace '\s', '').ToUpperInvariant(),
$normalizedExpectedThumbprint,
[System.StringComparison]::Ordinal
)
) "$ArtifactLabel signer does not match the imported production certificate."
} else {
Assert-True (
[string]::Equals(
[string]$Signature.SignerCertificate.Subject,
$ExpectedSubject,
[System.StringComparison]::Ordinal
)
) "$ArtifactLabel signer subject does not match the approved production identity."
}
Assert-True ($null -ne $Signature.TimeStamperCertificate) `
"$ArtifactLabel has no validated Authenticode timestamp certificate."
}
function Assert-LifecycleReceiptApprovedSigner {
param(
[Parameter(Mandatory = $true)] [object]$Receipt,
[AllowEmptyString()] [string]$ExpectedThumbprint,
[AllowEmptyString()] [string]$ExpectedSubject
)
$hasExpectedThumbprint = -not [string]::IsNullOrWhiteSpace($ExpectedThumbprint)
$hasExpectedSubject = -not [string]::IsNullOrWhiteSpace($ExpectedSubject)
Assert-True ($hasExpectedThumbprint -xor $hasExpectedSubject) `
'Exactly one expected signer identity binding is required.'
$expectedSigner = if ($hasExpectedSubject) {
$ExpectedSubject
} else {
$normalizedExpectedThumbprint = ($ExpectedThumbprint -replace '\s', '').ToUpperInvariant()
Assert-True ($normalizedExpectedThumbprint -match '^[0-9A-F]{40}$') `
'Expected signer thumbprint must be exactly 40 hexadecimal characters.'
$normalizedExpectedThumbprint
}
$comparison = if ($hasExpectedSubject) {
[System.StringComparison]::Ordinal
} else {
[System.StringComparison]::OrdinalIgnoreCase
}
foreach ($artifact in @(
$Receipt.previousInstaller,
$Receipt.previousInstalledApp,
$Receipt.installer,
$Receipt.installedApp
)) {
$actualSigner = if ($hasExpectedSubject) {
[string]$artifact.signerSubject
} else {
[string]$artifact.signerThumbprint
}
Assert-True ([string]::Equals(
$actualSigner,
$expectedSigner,
$comparison
)) 'Previous and candidate artifacts are not signed by the same approved signer.'
}
}
function ConvertTo-StrictSemanticVersion {
param(
[Parameter(Mandatory = $true)] [string]$Value,
[Parameter(Mandatory = $true)] [string]$Label
)
Assert-True ($Value -match '^\d+\.\d+\.\d+$') `
"$Label must use strict numeric x.y.z format."
return [version]$Value
}
function Get-InstalledProductVersion {
param(
[Parameter(Mandatory = $true)] [string]$ExecutablePath,
[Parameter(Mandatory = $true)] [string]$ArtifactLabel
)
$rawVersion = [string](Get-Item -LiteralPath $ExecutablePath).VersionInfo.ProductVersion
Assert-True (-not [string]::IsNullOrWhiteSpace($rawVersion)) `
"$ArtifactLabel has no embedded product version."
Assert-True ($rawVersion -match '^(?<version>\d+\.\d+\.\d+)(?:\.0)?(?:[+-].*)?$') `
"$ArtifactLabel product version is not a supported x.y.z value: $rawVersion"
return [ordered]@{
raw = $rawVersion
normalized = $Matches['version']
}
}
function New-AuthenticodeArtifactReceipt {
param(
[Parameter(Mandatory = $true)] [System.IO.FileInfo]$File,
[Parameter(Mandatory = $true)] [object]$Signature
)
$artifact = [ordered]@{
name = $File.Name
sha256 = (Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256).Hash
sizeBytes = $File.Length
authenticodeStatus = [string]$Signature.Status
signatureType = [string]$Signature.SignatureType
signerSubject = $null
signerThumbprint = $null
timestampAuthoritySubject = $null
timestampAuthorityThumbprint = $null
timestampAuthorityNotBefore = $null
timestampAuthorityNotAfter = $null
}
if ($Signature.SignerCertificate) {
$artifact.signerSubject = $Signature.SignerCertificate.Subject
$artifact.signerThumbprint = $Signature.SignerCertificate.Thumbprint
}
if ($Signature.TimeStamperCertificate) {
$artifact.timestampAuthoritySubject = $Signature.TimeStamperCertificate.Subject
$artifact.timestampAuthorityThumbprint = $Signature.TimeStamperCertificate.Thumbprint
$artifact.timestampAuthorityNotBefore = $Signature.TimeStamperCertificate.NotBefore.ToUniversalTime().ToString('o')
$artifact.timestampAuthorityNotAfter = $Signature.TimeStamperCertificate.NotAfter.ToUniversalTime().ToString('o')
}
return $artifact
}
function Assert-ArtifactIdentity {
param(
[Parameter(Mandatory = $true)] [string]$FilePath,
[Parameter(Mandatory = $true)] [string]$ExpectedSha256,
[AllowEmptyString()] [string]$ExpectedSignerThumbprint,
[AllowEmptyString()] [string]$ExpectedSignerSubject,
[Parameter(Mandatory = $true)] [string]$ArtifactLabel
)
Assert-True (Test-Path -LiteralPath $FilePath -PathType Leaf) `
"$ArtifactLabel is missing."
$actualSha256 = (Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash
Assert-True ([string]::Equals(
$actualSha256,
$ExpectedSha256,
[System.StringComparison]::OrdinalIgnoreCase
)) "$ArtifactLabel SHA-256 changed during certification."
$signature = Get-AuthenticodeSignature -LiteralPath $FilePath
Assert-ExpectedAuthenticodeSignature `
$signature $ExpectedSignerThumbprint $ExpectedSignerSubject $ArtifactLabel
}
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Loopback,
0
)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Assert-TcpPortAvailable {
param([Parameter(Mandatory = $true)] [int]$Port)
Assert-True (Test-TcpPortAvailable $Port) `
"Required desktop port $Port is already in use; refusing to disturb another service."
}
function Assert-VaultKeyAclRestricted {
param([Parameter(Mandatory = $true)] [string]$KeyPath)
Assert-True (Test-Path -LiteralPath $KeyPath -PathType Leaf) `
'Windows vault key was not created during first boot.'
$acl = Get-Acl -LiteralPath $KeyPath
Assert-True ($acl.AreAccessRulesProtected) `
'Windows vault key still inherits filesystem permissions.'
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
Assert-True ($null -ne $currentSid) 'Could not resolve the current Windows security identifier.'
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier])
Assert-True ([string]::Equals(
$ownerSid.Value,
$currentSid.Value,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Windows vault key is not owned by the current user.'
$rules = @(
$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
)
Assert-True ($rules.Count -eq 1) `
"Windows vault key must have exactly one access rule; found $($rules.Count)."
$allowRules = @(
$rules |
Where-Object {
$_.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow
}
)
$currentUserAllows = @(
$allowRules | Where-Object {
[string]::Equals(
$_.IdentityReference.Value,
$currentSid.Value,
[System.StringComparison]::OrdinalIgnoreCase
)
}
)
$unexpectedAllows = @(
$allowRules | Where-Object {
-not [string]::Equals(
$_.IdentityReference.Value,
$currentSid.Value,
[System.StringComparison]::OrdinalIgnoreCase
)
}
)
$unexpectedPrincipals = @(
$unexpectedAllows | ForEach-Object { $_.IdentityReference.Value }
)
Assert-True ($unexpectedAllows.Count -eq 0) `
"Windows vault key grants access to unexpected principals: $($unexpectedPrincipals -join ', ')"
$fullControl = [Security.AccessControl.FileSystemRights]::FullControl
$hasCurrentUserFullControl = @(
$currentUserAllows | Where-Object {
($_.FileSystemRights -band $fullControl) -eq $fullControl
}
).Count -gt 0
Assert-True $hasCurrentUserFullControl `
'Windows vault key does not grant the current user full control.'
}
function Test-TcpPortAvailable {
param([Parameter(Mandatory = $true)] [int]$Port)
$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Loopback,
$Port
)
try {
$listener.Start()
return $true
} catch {
return $false
} finally {
$listener.Stop()
}
}
function Stop-StartedProcessTree {
param([Parameter(Mandatory = $true)] [System.Diagnostics.Process]$Process)
$taskkill = Join-Path $env:SystemRoot 'System32\taskkill.exe'
$killInfo = [System.Diagnostics.ProcessStartInfo]::new()
$killInfo.FileName = $taskkill
$killInfo.Arguments = "/PID $($Process.Id) /T /F"
$killInfo.UseShellExecute = $false
$killInfo.CreateNoWindow = $true
$killInfo.RedirectStandardOutput = $true
$killInfo.RedirectStandardError = $true
$killInfo.WorkingDirectory = Split-Path -Parent $taskkill
$killProcess = [System.Diagnostics.Process]::new()
$killProcess.StartInfo = $killInfo
try {
Assert-True ($killProcess.Start()) "Could not start taskkill for process $($Process.Id)"
Assert-True ($killProcess.WaitForExit(20000)) "Timed out terminating process tree $($Process.Id)"
$killProcess.WaitForExit()
if ($killProcess.ExitCode -ne 0) {
$detail = $killProcess.StandardError.ReadToEnd().Trim()
throw "Could not terminate process tree $($Process.Id): $detail"
}
Assert-True ($Process.WaitForExit(20000)) "Process tree root $($Process.Id) did not exit"
} finally {
$killProcess.Dispose()
}
}
function Remove-CertificationControlEnvironment {
param([Parameter(Mandatory = $true)] [System.Diagnostics.ProcessStartInfo]$Info)
$explicitNames = @(
'CI', 'GH_TOKEN', 'GITHUB_TOKEN',
'WINDOWS_CODESIGN_PFX_BASE64', 'WINDOWS_CODESIGN_PFX_PASSWORD',
'WINDOWS_CODESIGN_APPROVED_SUBJECT', 'WINDOWS_CODESIGN_APPROVED_THUMBPRINT',
'WINDOWS_UPGRADE_BASE_SHA256', 'WAGGLE_APPROVED_CODESIGN_SUBJECT',
'WAGGLE_APPROVED_CODESIGN_THUMBPRINT', 'WAGGLE_CODESIGN_SUBJECT',
'WAGGLE_CODESIGN_THUMBPRINT', 'WAGGLE_RELEASE_VERSION'
)
foreach ($name in @($Info.Environment.Keys)) {
if ($name -match '^(?:ACTIONS_|GITHUB_|RUNNER_|WAGGLE_UPGRADE_BASE_)' -or
$explicitNames -contains $name) {
$null = $Info.Environment.Remove($name)
}
}
}
function Test-TcpPortHasListener {
param([Parameter(Mandatory = $true)] [int]$Port)
return @(
[System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners() |
Where-Object { $_.Port -eq $Port }
).Count -gt 0
}
function Invoke-RawProcess {
param(
[Parameter(Mandatory = $true)] [string]$FilePath,
[Parameter(Mandatory = $true)] [string]$Arguments,
[ValidateRange(1, 900)] [int]$TimeoutSeconds = 300
)
$info = [System.Diagnostics.ProcessStartInfo]::new()
$info.FileName = $FilePath
$info.Arguments = $Arguments
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.WorkingDirectory = Split-Path -Parent $FilePath
Remove-CertificationControlEnvironment $info
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $info
try {
Assert-True ($process.Start()) "Could not start $FilePath"
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
Stop-StartedProcessTree $process
throw "Timed out after ${TimeoutSeconds}s: $FilePath $Arguments"
}
$process.WaitForExit()
if ($process.ExitCode -ne 0) {
throw "Process exited $($process.ExitCode): $FilePath $Arguments"
}
} finally {
$process.Dispose()
}
}
function Start-InstalledApp {
param(
[Parameter(Mandatory = $true)] [string]$ExecutablePath,
[ValidateRange(1024, 65535)] [int]$DebugPort = 0
)
$info = [System.Diagnostics.ProcessStartInfo]::new()
$info.FileName = $ExecutablePath
$info.UseShellExecute = $false
$info.WorkingDirectory = Split-Path -Parent $ExecutablePath
Remove-CertificationControlEnvironment $info
if ($DebugPort -gt 0) {
$info.Environment['WAGGLE_CERTIFIER_WEBVIEW_DEBUG_PORT'] = [string]$DebugPort
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $info
Assert-True ($process.Start()) "Could not start installed Waggle: $ExecutablePath"
return $process
}
function Invoke-JsonRequest {
param(
[Parameter(Mandatory = $true)] [string]$Uri,
[hashtable]$Headers = @{},
[ValidateRange(1, 30)] [int]$TimeoutSeconds = 5
)
return Invoke-RestMethod -Uri $Uri -Method Get -Headers $Headers -TimeoutSec $TimeoutSeconds
}
function Invoke-BuiltInProxyLivenessProbe {
param(
[Parameter(Mandatory = $true)] [string]$Uri,
[ValidateRange(1, 30)] [int]$AttemptTimeoutSeconds = 5,
[ValidateRange(1, 2)] [int]$MaxAttempts = 2,
[ValidateRange(0, 2000)] [int]$RetryDelayMilliseconds = 250
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) {
try {
return Invoke-JsonRequest -Uri $Uri -TimeoutSeconds $AttemptTimeoutSeconds
} catch {
if ($attempt -ge $MaxAttempts) { throw }
if ($RetryDelayMilliseconds -gt 0) {
Start-Sleep -Milliseconds $RetryDelayMilliseconds
}
}
}
}
function Test-TransientLoopbackRequestFailure {
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$responseProperty = $ErrorRecord.Exception.PSObject.Properties['Response']
if ($null -ne $responseProperty) {
$response = $responseProperty.Value
try {
return @(408, 425, 429, 500, 502, 503, 504) -contains [int]$response.StatusCode
} catch {
return $false
}
}
$exception = $ErrorRecord.Exception
return (
$exception -is [System.Net.Http.HttpRequestException] -or
$exception -is [System.Net.WebException] -or
$exception -is [System.Threading.Tasks.TaskCanceledException] -or
$exception -is [System.TimeoutException]
)
}
function Invoke-SessionTokenBootstrapProbe {
param(
[Parameter(Mandatory = $true)] [string]$Uri,
[hashtable]$Headers = @{},
[ValidateRange(1, 30)] [int]$AttemptTimeoutSeconds = 5,
[ValidateRange(1, 2)] [int]$MaxAttempts = 2,
[ValidateRange(0, 2000)] [int]$RetryDelayMilliseconds = 250
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) {
try {
return Invoke-JsonRequest `
-Uri $Uri `
-Headers $Headers `
-TimeoutSeconds $AttemptTimeoutSeconds
} catch {
if (
$attempt -ge $MaxAttempts -or
-not (Test-TransientLoopbackRequestFailure -ErrorRecord $_)
) {
throw
}
if ($RetryDelayMilliseconds -gt 0) {
Start-Sleep -Milliseconds $RetryDelayMilliseconds
}
}
}
}
function Invoke-JsonPostRequest {
param(
[Parameter(Mandatory = $true)] [string]$Uri,
[Parameter(Mandatory = $true)] [hashtable]$Body,
[hashtable]$Headers = @{},
[ValidateRange(1, 3600)] [int]$TimeoutSeconds = 30
)
$json = $Body | ConvertTo-Json -Compress -Depth 8
return Invoke-WebRequest `
-Uri $Uri `
-Method Post `
-Headers $Headers `
-ContentType 'application/json; charset=utf-8' `
-Body ([System.Text.Encoding]::UTF8.GetBytes($json)) `
-TimeoutSec $TimeoutSeconds `
-UseBasicParsing
}
function Get-CertificateSessionHeaders {
param(
[Parameter(Mandatory = $true)] [string]$BaseUrl,
[Parameter(Mandatory = $true)] [string]$NodeExecutable,
[Parameter(Mandatory = $true)] [string]$BootstrapHelperPath,
[ValidateRange(1024, 65535)] [int]$DebugPort,
[switch]$AllowLegacyUi
)
Assert-True (Test-Path -LiteralPath $BootstrapHelperPath -PathType Leaf) `
'Tauri bootstrap helper is missing.'
$stderrPath = Join-Path ([System.IO.Path]::GetTempPath()) `
"waggle-bootstrap-token-$([Guid]::NewGuid().ToString('N')).stderr.log"
$tokenResponse = $null
try {
$helperArguments = @(
'--experimental-websocket',
$BootstrapHelperPath,
'--port',
[string]$DebugPort,
'--timeout-ms',
'60000'
)
if ($AllowLegacyUi) { $helperArguments += '--allow-legacy-ui' }
$tokenJson = & $NodeExecutable @helperArguments 2> $stderrPath
$tokenExitCode = $LASTEXITCODE
$tokenErrorRaw = if (Test-Path -LiteralPath $stderrPath) {
Get-Content -Raw -LiteralPath $stderrPath
} else {
''
}
$tokenError = (@($tokenErrorRaw) -join [Environment]::NewLine).Trim()
Assert-True ($tokenExitCode -eq 0) `
"Tauri bootstrap IPC helper failed: $tokenError"
$tokenJsonText = ([string](@($tokenJson) -join [Environment]::NewLine)).Trim()
$tokenPayload = $null
if (-not [string]::IsNullOrWhiteSpace($tokenJsonText)) {
try {
$tokenPayload = ConvertFrom-Json -InputObject $tokenJsonText -ErrorAction Stop
} catch {
$tokenPayload = $null
}
}
Assert-True ($null -ne $tokenPayload) `
'Tauri bootstrap IPC helper returned invalid JSON output.'
$bootstrapTokenProperty = $tokenPayload.PSObject.Properties['bootstrapToken']
Assert-True ($null -ne $bootstrapTokenProperty) `
'Tauri bootstrap IPC helper returned no credential field.'
$uiReadyProperty = $tokenPayload.PSObject.Properties['uiReady']
$uiStartupStateProperty = $tokenPayload.PSObject.Properties['uiStartupState']
$uiPathProperty = $tokenPayload.PSObject.Properties['uiPath']
$uiTextLengthProperty = $tokenPayload.PSObject.Properties['uiTextLength']
Assert-True (
$null -ne $uiReadyProperty -and
$uiReadyProperty.Value -is [bool] -and
$uiReadyProperty.Value
) 'Installed Waggle WebView did not render its application shell.'
Assert-True (
$null -ne $uiStartupStateProperty -and
$uiStartupStateProperty.Value -is [string] -and
[string]$uiStartupStateProperty.Value -ceq $(
if ($AllowLegacyUi) { 'legacy-ready' } else { 'ready' }
)
) 'Installed Waggle WebView did not commit its application shell.'
Assert-True (
$null -ne $uiPathProperty -and
$uiPathProperty.Value -is [string] -and
([string]$uiPathProperty.Value).StartsWith('/')
) 'Installed Waggle WebView returned an invalid application route.'
Assert-True (
$null -ne $uiTextLengthProperty -and
($uiTextLengthProperty.Value -is [int] -or $uiTextLengthProperty.Value -is [long]) -and
[long]$uiTextLengthProperty.Value -gt 0
) 'Installed Waggle WebView rendered no visible application content.'
$bootstrapToken = [string]$bootstrapTokenProperty.Value
Assert-True ($bootstrapToken.Length -ge 32 -and $bootstrapToken.Length -le 200) `
'Tauri bootstrap IPC helper returned an invalid credential.'
$tokenResponse = Invoke-SessionTokenBootstrapProbe `
-Uri "$BaseUrl/api/auth/session-token" `
-Headers @{ 'x-waggle-desktop-bootstrap' = $bootstrapToken }
} finally {
if (Test-Path -LiteralPath $stderrPath) {
Remove-Item -LiteralPath $stderrPath -Force -ErrorAction SilentlyContinue
}
}
Assert-True ($null -ne $tokenResponse) `
'Session-token bootstrap returned no JSON response.'
$sessionTokenProperty = $tokenResponse.PSObject.Properties['token']
Assert-True ($null -ne $sessionTokenProperty) `
'Session-token bootstrap returned no token field.'
$sessionToken = [string]$sessionTokenProperty.Value
Assert-True (-not [string]::IsNullOrWhiteSpace($sessionToken)) `
'Session-token bootstrap returned no token.'
return @{ Authorization = "Bearer $sessionToken" }
}
function Assert-CertificateLifecycleData {
param(
[Parameter(Mandatory = $true)] [string]$BaseUrl,
[Parameter(Mandatory = $true)] [hashtable]$Headers,
[Parameter(Mandatory = $true)] [object]$State,
[Parameter(Mandatory = $true)] [string]$DataDir
)
$escapedWorkspaceId = [uri]::EscapeDataString([string]$State.workspaceId)
$workspace = Invoke-JsonRequest "$BaseUrl/api/workspaces/$escapedWorkspaceId" $Headers
Assert-True ([string]$workspace.id -ceq [string]$State.workspaceId) `
'Persisted workspace id changed or disappeared.'
Assert-True ([string]$workspace.name -ceq [string]$State.workspaceName) `
'Persisted workspace name changed or disappeared.'
Assert-True ([string]$workspace.group -ceq [string]$State.workspaceGroup) `
'Persisted workspace group changed or disappeared.'
$personalList = Invoke-JsonRequest "$BaseUrl/api/memory/frames?limit=200" $Headers
$personalResults = @($personalList.results)
Assert-True ([long]$personalList.count -eq $personalResults.Count) `
'Personal memory response count does not match its result set.'
$personalMatches = @($personalResults | Where-Object {
[long]$_.id -eq [long]$State.personalFrameId -and
[string]$_.mind -ceq 'personal' -and
[string]$_.source_mind -ceq 'personal' -and
[string]$_.content -ceq [string]$State.personalContent
})
Assert-True ($personalMatches.Count -eq 1) `
'Persisted personal memory marker is missing or ambiguous.'
$workspaceList = Invoke-JsonRequest `
"$BaseUrl/api/memory/frames?workspaceId=$escapedWorkspaceId&limit=200" `
$Headers
$workspaceResults = @($workspaceList.results)
Assert-True ([long]$workspaceList.count -eq $workspaceResults.Count) `
'Workspace memory response count does not match its result set.'
$workspaceMatches = @($workspaceResults | Where-Object {
[long]$_.id -eq [long]$State.workspaceFrameId -and
[string]$_.mind -ceq 'workspace' -and
[string]$_.source_mind -ceq 'workspace' -and
[string]$_.content -ceq [string]$State.workspaceContent
})
Assert-True ($workspaceMatches.Count -eq 1) `
'Persisted workspace memory marker is missing or ambiguous.'
foreach ($frame in @($personalMatches[0], $workspaceMatches[0])) {
Assert-True ([string]$frame.source -ceq 'tool_verified') `
'Persisted lifecycle memory lost its provenance.'
Assert-True ([string]$frame.importance -ceq 'important') `
'Persisted lifecycle memory lost its importance.'
Assert-True (@('I', 'P', 'B') -contains [string]$frame.frameType) `
'Persisted lifecycle memory returned an invalid frame type.'
Assert-True (Test-CertificateTimestamp $frame.timestamp) `
'Persisted lifecycle memory returned an invalid timestamp.'
$parsedAccessCount = 0L
Assert-True ([long]::TryParse([string]$frame.accessCount, [ref]$parsedAccessCount)) `
'Persisted lifecycle memory returned an invalid access count.'
}
$workspaceDir = Join-Path $DataDir "workspaces\$($State.workspaceId)"
foreach ($path in @(
(Join-Path $DataDir 'personal.mind'),
(Join-Path $workspaceDir 'workspace.json'),
(Join-Path $workspaceDir 'workspace.mind')
)) {
Assert-True (Test-Path -LiteralPath $path -PathType Leaf) `
"Lifecycle data file is missing: $path"
}
foreach ($path in @(
(Join-Path $workspaceDir 'sessions'),
(Join-Path $workspaceDir 'files\attachments'),
(Join-Path $workspaceDir 'files\exports'),
(Join-Path $workspaceDir 'files\notes')
)) {
Assert-True (Test-Path -LiteralPath $path -PathType Container) `
"Lifecycle workspace directory is missing: $path"
}
}
function New-CertificateLifecycleData {
param(
[Parameter(Mandatory = $true)] [string]$BaseUrl,
[Parameter(Mandatory = $true)] [hashtable]$Headers,
[Parameter(Mandatory = $true)] [string]$RunId,
[Parameter(Mandatory = $true)] [string]$DataDir
)
$workspaceName = "Installer certificate $RunId"
$workspaceGroup = 'Installer certificate'
$workspaceResponse = Invoke-JsonPostRequest "$BaseUrl/api/workspaces" @{
name = $workspaceName
group = $workspaceGroup
} $Headers
$workspace = $workspaceResponse.Content | ConvertFrom-Json
Assert-True ([int]$workspaceResponse.StatusCode -eq 201) `
'Could not create the lifecycle certificate workspace.'
Assert-True ([string]$workspace.id -match '^[a-z0-9]+(?:-[a-z0-9]+)*$') `
'Lifecycle certificate workspace returned an invalid id.'
Assert-True ([string]$workspace.name -ceq $workspaceName) `
'Lifecycle certificate workspace returned an unexpected name.'
Assert-True ([string]$workspace.group -ceq $workspaceGroup) `
'Lifecycle certificate workspace returned an unexpected group.'
Assert-True (Test-CertificateTimestamp $workspace.created) `
'Lifecycle certificate workspace returned an invalid creation timestamp.'
$personalContent = "installer-certificate-personal-memory-$RunId"
$personalResponse = Invoke-JsonPostRequest "$BaseUrl/api/memory/frames?extract=false" @{
content = $personalContent
importance = 'important'
source = 'tool_verified'
} $Headers
$personal = $personalResponse.Content | ConvertFrom-Json
Assert-True (
[int]$personalResponse.StatusCode -eq 200 -and
$personal.saved -eq $true -and
[string]$personal.mind -ceq 'personal' -and
[string]$personal.importance -ceq 'important' -and
[string]$personal.source -ceq 'tool_verified'
) 'Could not seed the lifecycle certificate personal memory.'
Assert-True ($personal.frameId -is [int] -or $personal.frameId -is [long]) `
'Lifecycle certificate personal memory returned no numeric frame id.'
$workspaceContent = "installer-certificate-workspace-memory-$RunId"
$workspaceMemoryResponse = Invoke-JsonPostRequest "$BaseUrl/api/memory/frames?extract=false" @{
content = $workspaceContent
workspaceId = [string]$workspace.id
importance = 'important'
source = 'tool_verified'
} $Headers
$workspaceMemory = $workspaceMemoryResponse.Content | ConvertFrom-Json
Assert-True (
[int]$workspaceMemoryResponse.StatusCode -eq 200 -and
$workspaceMemory.saved -eq $true -and
[string]$workspaceMemory.mind -ceq 'workspace' -and
[string]$workspaceMemory.importance -ceq 'important' -and
[string]$workspaceMemory.source -ceq 'tool_verified'
) 'Could not seed the lifecycle certificate workspace memory.'
Assert-True ($workspaceMemory.frameId -is [int] -or $workspaceMemory.frameId -is [long]) `
'Lifecycle certificate workspace memory returned no numeric frame id.'
$state = [ordered]@{
workspaceId = [string]$workspace.id
workspaceName = $workspaceName
workspaceGroup = $workspaceGroup
personalContent = $personalContent
personalFrameId = [long]$personal.frameId
workspaceContent = $workspaceContent
workspaceFrameId = [long]$workspaceMemory.frameId
}
Assert-CertificateLifecycleData $BaseUrl $Headers $state $DataDir
return $state
}
function Get-CertificateRelativePath {
param(
[Parameter(Mandatory = $true)] [string]$Root,
[Parameter(Mandatory = $true)] [string]$Child
)
$resolvedRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + '\'
$resolvedChild = [System.IO.Path]::GetFullPath($Child)
Assert-True (
$resolvedChild.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)
) "Certificate profile entry escapes the owned root: $resolvedChild"
return $resolvedChild.Substring($resolvedRoot.Length).Replace('\', '/')
}
function Get-CertificateDataManifest {
param(
[Parameter(Mandatory = $true)] [string]$ProfileDataDir,
[switch]$SkipHashes
)
$resolvedRoot = [System.IO.Path]::GetFullPath($ProfileDataDir).TrimEnd('\')
Assert-True (Test-Path -LiteralPath $resolvedRoot -PathType Container) `
"Certificate profile directory is missing: $resolvedRoot"
$root = Get-Item -LiteralPath $resolvedRoot -Force
Assert-True (($root.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Certificate profile root must not be a reparse point: $resolvedRoot"
$queue = [System.Collections.Generic.Queue[System.IO.DirectoryInfo]]::new()
$queue.Enqueue([System.IO.DirectoryInfo]$root)
$paths = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
$entries = @()
while ($queue.Count -gt 0) {
$directory = $queue.Dequeue()
foreach ($item in @(Get-ChildItem -LiteralPath $directory.FullName -Force)) {
Assert-True (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Certificate profile contains a reparse point: $($item.FullName)"
$relativePath = Get-CertificateRelativePath $resolvedRoot $item.FullName
Assert-True ($paths.Add($relativePath)) `
"Certificate profile contains an ambiguous path: $relativePath"
if ($item.PSIsContainer) {
$entries += [ordered]@{
path = $relativePath
type = 'directory'
sizeBytes = 0L
sha256 = $null
}
$queue.Enqueue([System.IO.DirectoryInfo]$item)
} else {
$entries += [ordered]@{
path = $relativePath
type = 'file'
sizeBytes = [long]$item.Length
sha256 = if ($SkipHashes) {
$null
} else {
(Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash
}
}
}
}
}
return @($entries | Sort-Object { [string]$_.path })
}
function Assert-CertificateDataManifest {
param(
[Parameter(Mandatory = $true)] [object[]]$Expected,
[Parameter(Mandatory = $true)] [object[]]$Actual
)
Assert-True ($Actual.Count -eq $Expected.Count) `
'Silent uninstall changed the default-profile manifest entry count.'
for ($index = 0; $index -lt $Expected.Count; $index++) {
foreach ($property in @('path', 'type', 'sizeBytes', 'sha256')) {
Assert-True (
[string]::Equals(
[string]$Actual[$index][$property],
[string]$Expected[$index][$property],
[System.StringComparison]::Ordinal
)
) "Silent uninstall changed default-profile manifest entry '$($Expected[$index].path)' ($property)."
}
}
}
function Get-CertificateDataManifestDigest {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyCollection()]
[object[]]$manifest
)
$payload = ConvertTo-Json -InputObject @($manifest) -Compress -Depth 4
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$digest = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payload))
return ([System.BitConverter]::ToString($digest)).Replace('-', '')
} finally {
$sha256.Dispose()
}
}
function Get-SidecarProvenance {
param([Parameter(Mandatory = $true)] [string]$Path)
Assert-True (Test-Path -LiteralPath $Path -PathType Leaf) `
"Sidecar bundle is missing: $Path"
$file = Get-Item -LiteralPath $Path
Assert-True (($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Sidecar bundle must not be a reparse point: $Path"
$bytes = [System.IO.File]::ReadAllBytes($file.FullName)
$newlineIndex = [Array]::IndexOf($bytes, [byte]10)
Assert-True ($newlineIndex -gt 0) 'Sidecar bundle is missing embedded provenance.'
$firstLine = [System.Text.Encoding]::UTF8.GetString($bytes, 0, $newlineIndex)
$prefix = '// Waggle-Sidecar-Provenance: '
Assert-True ($firstLine.StartsWith($prefix, [System.StringComparison]::Ordinal)) `
'Sidecar bundle is missing embedded provenance.'
$encoded = $firstLine.Substring($prefix.Length)
Assert-True (
$encoded.Length -gt 0 -and
($encoded.Length % 4) -eq 0 -and
$encoded -cmatch '^[A-Za-z0-9+/]+={0,2}$'
) 'Sidecar embedded provenance is not canonical base64.'
try {
$jsonBytes = [Convert]::FromBase64String($encoded)
} catch {
throw 'Sidecar embedded provenance is not canonical base64.'
}
Assert-True (
[string]::Equals(
[Convert]::ToBase64String($jsonBytes),
$encoded,
[System.StringComparison]::Ordinal
)
) 'Sidecar embedded provenance is not canonical base64.'
try {
$manifest = [System.Text.Encoding]::UTF8.GetString($jsonBytes) |
ConvertFrom-Json
} catch {
throw 'Sidecar embedded provenance is not valid JSON.'
}
Assert-True ($null -ne $manifest -and [int]$manifest.schemaVersion -eq 1) `
'Sidecar embedded provenance schemaVersion must be 1.'
Assert-True ([string]$manifest.sourceRevision -cmatch '^[0-9a-f]{40}$') `
'Sidecar embedded provenance sourceRevision is invalid.'
Assert-True (
[string]::Equals(
[string]$manifest.entryPoint,
'packages/server/src/local/service.ts',
[System.StringComparison]::Ordinal
)
) 'Sidecar embedded provenance entryPoint is invalid.'
$sourceInputs = @($manifest.sourceInputs)
Assert-True ($sourceInputs.Count -gt 0) 'Sidecar embedded provenance sourceInputs are missing.'
$requiredInputs = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::Ordinal
)
foreach ($required in @(
'package-lock.json',
'package.json',
'packages/server/src/local/service.ts',
'scripts/build-sidecar.mjs'
)) {
[void]$requiredInputs.Add($required)
}
$previousPath = $null
foreach ($input in $sourceInputs) {
$relative = [string]$input.path
$parts = @($relative.Split('/'))
Assert-True (
-not [string]::IsNullOrWhiteSpace($relative) -and
-not $relative.Contains('\') -and
-not $relative.Contains([char]0) -and
-not [System.IO.Path]::IsPathRooted($relative.Replace('/', '\')) -and
-not ($parts | Where-Object { $_ -in @('', '.', '..', 'node_modules') })
) 'Sidecar embedded provenance source input path is unsafe.'
if ($null -ne $previousPath) {
Assert-True ([string]::CompareOrdinal($previousPath, $relative) -lt 0) `
'Sidecar embedded provenance source inputs are not unique and sorted.'
}
Assert-True ([string]$input.sha256 -cmatch '^[0-9a-f]{64}$') `
'Sidecar embedded provenance source input hash is invalid.'
$previousPath = $relative
[void]$requiredInputs.Remove($relative)
}
Assert-True ($requiredInputs.Count -eq 0) `
'Sidecar embedded provenance omits required source inputs.'
$payloadOffset = $newlineIndex + 1
$payloadLength = $bytes.Length - $payloadOffset
Assert-True (
$null -ne $manifest.bundlePayload -and
[long]$manifest.bundlePayload.sizeBytes -eq $payloadLength -and
[string]$manifest.bundlePayload.sha256 -cmatch '^[0-9a-f]{64}$'
) 'Sidecar bundle payload does not match embedded provenance.'
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$payloadDigest = $sha256.ComputeHash($bytes, $payloadOffset, $payloadLength)
} finally {
$sha256.Dispose()
}
$payloadSha256 = ([System.BitConverter]::ToString($payloadDigest)).Replace('-', '')
Assert-True (
[string]::Equals(
$payloadSha256,
[string]$manifest.bundlePayload.sha256,
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Sidecar bundle payload does not match embedded provenance.'
$provenanceSha = [System.Security.Cryptography.SHA256]::Create()
try {
$provenanceDigest = $provenanceSha.ComputeHash($jsonBytes)
} finally {
$provenanceSha.Dispose()
}
return [ordered]@{
manifest = $manifest
bundleSha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
provenanceSha256 = ([System.BitConverter]::ToString($provenanceDigest)).Replace('-', '')
payloadSha256 = $payloadSha256
sourceInputCount = $sourceInputs.Count
}
}
function Assert-SidecarBundleBinding {
param(
[Parameter(Mandatory = $true)] [object]$Packaged,
[Parameter(Mandatory = $true)] [object]$Installed
)
Assert-True (
[string]::Equals(
[string]$Installed.bundleSha256,
[string]$Packaged.bundleSha256,
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Installed resources/service.js does not match the source-bound bundle.'
Assert-True (
[string]::Equals(
[string]$Installed.provenanceSha256,
[string]$Packaged.provenanceSha256,
[System.StringComparison]::OrdinalIgnoreCase
) -and
[string]::Equals(
[string]$Installed.manifest.sourceRevision,
[string]$Packaged.manifest.sourceRevision,
[System.StringComparison]::Ordinal
) -and
[int]$Installed.sourceInputCount -eq [int]$Packaged.sourceInputCount
) 'Installed resources/service.js provenance does not match the certified source.'
}
function Assert-SidecarSourceBinding {
param(
[Parameter(Mandatory = $true)] [object]$Provenance,
[Parameter(Mandatory = $true)] [string]$RepositoryRoot,
[Parameter(Mandatory = $true)] [string]$ExpectedRevision,
[Parameter(Mandatory = $true)] [string]$GitExecutable
)
Assert-True (
[string]::Equals(
[string]$Provenance.manifest.sourceRevision,
$ExpectedRevision,
[System.StringComparison]::Ordinal
)
) 'Sidecar provenance revision does not match expected source revision.'
$repositoryRootItem = Get-Item -LiteralPath $RepositoryRoot -ErrorAction Stop
Assert-True $repositoryRootItem.PSIsContainer `
"Sidecar provenance repository root is not a directory: $RepositoryRoot"
$resolvedRepositoryRoot = $repositoryRootItem.FullName.TrimEnd('\')
$repositoryPrefix = $resolvedRepositoryRoot + '\'
$sourceInputs = @($Provenance.manifest.sourceInputs)
$sourcePaths = @($sourceInputs | ForEach-Object { [string]$_.path })
foreach ($input in $sourceInputs) {
$relative = [string]$input.path
$sourcePath = [System.IO.Path]::GetFullPath(
(Join-Path $resolvedRepositoryRoot ($relative.Replace('/', '\')))
)
Assert-True (
$sourcePath.StartsWith($repositoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)
) "Sidecar provenance source path escapes the repository: $relative"
Assert-True (Test-Path -LiteralPath $sourcePath -PathType Leaf) `
"Sidecar provenance source input is missing: $relative"
$sourceFile = Get-Item -LiteralPath $sourcePath
Assert-True (($sourceFile.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Sidecar provenance source input is a reparse point: $relative"
Assert-True (
[string]::Equals(
(Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash,
[string]$input.sha256,
[System.StringComparison]::OrdinalIgnoreCase
)
) "Sidecar provenance source hash changed: $relative"
& $GitExecutable -C $RepositoryRoot ls-files --error-unmatch -- $relative 2>$null |
Out-Null
Assert-True ($LASTEXITCODE -eq 0) "Sidecar provenance source is not tracked: $relative"
& $GitExecutable -C $RepositoryRoot cat-file -e "${ExpectedRevision}:$relative" 2>$null
Assert-True ($LASTEXITCODE -eq 0) `
"Sidecar provenance source is absent from expected revision: $relative"
}
for ($offset = 0; $offset -lt $sourcePaths.Count; $offset += 100) {
$lastIndex = [Math]::Min($offset + 99, $sourcePaths.Count - 1)
$sourceChunk = @($sourcePaths[$offset..$lastIndex])
$diffArguments = @(
'-C',
$RepositoryRoot,
'diff',
'--quiet',
$ExpectedRevision,
'--'
) + $sourceChunk
& $GitExecutable @diffArguments
Assert-True ($LASTEXITCODE -eq 0) `
'Sidecar provenance source inputs differ from the expected revision.'
}
}
function Get-ExternalProfileRootSnapshot {
param(
[Parameter(Mandatory = $true)] [string]$Name,
[Parameter(Mandatory = $true)] [string]$Path
)
$resolvedPath = [System.IO.Path]::GetFullPath($Path).TrimEnd('\')
$parentPath = [System.IO.Path]::GetDirectoryName($resolvedPath)
$leafName = [System.IO.Path]::GetFileName($resolvedPath)
Assert-True (-not [string]::IsNullOrWhiteSpace($parentPath)) `
"External profile root has no parent directory: $resolvedPath"
Assert-True (Test-Path -LiteralPath $parentPath -PathType Container) `
"External profile root parent is missing: $parentPath"
$matches = @(
Get-ChildItem -LiteralPath $parentPath -Force |
Where-Object {
[string]::Equals($_.Name, $leafName, [System.StringComparison]::OrdinalIgnoreCase)
}
)
Assert-True ($matches.Count -le 1) `
"External profile root has ambiguous directory entries: $resolvedPath"
if ($matches.Count -eq 0) {
return [ordered]@{
name = $Name
path = $resolvedPath
existedBefore = $false
entryCount = 0
manifestSha256 = $null
}
}
$root = $matches[0]
Assert-True ($root.PSIsContainer) `
"External profile root must be a directory: $resolvedPath"
Assert-True (($root.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"External profile root must not be a reparse point: $resolvedPath"
$manifest = @(Get-CertificateDataManifest $resolvedPath)
return [ordered]@{
name = $Name
path = $resolvedPath
existedBefore = $true
entryCount = $manifest.Count
manifestSha256 = (Get-CertificateDataManifestDigest $manifest)
}
}
function Assert-ExternalProfileRootsUnchanged {
param([Parameter(Mandatory = $true)] [object[]]$Expected)
foreach ($baseline in @($Expected)) {
if (-not [bool]$baseline.existedBefore) {
$actual = Get-ExternalProfileRootSnapshot `
-Name ([string]$baseline.name) `
-Path ([string]$baseline.path)
Assert-True (-not [bool]$actual.existedBefore) `
"Installer created external profile root '$($baseline.name)': $($baseline.path)"
continue
}
$actual = Get-ExternalProfileRootSnapshot `
-Name ([string]$baseline.name) `
-Path ([string]$baseline.path)
Assert-True ([bool]$actual.existedBefore) `
"Installer removed external profile root '$($baseline.name)': $($baseline.path)"
Assert-True ([long]$actual.entryCount -eq [long]$baseline.entryCount) `
"Installer changed external profile root entry count '$($baseline.name)'."
Assert-True ([string]::Equals(
[string]$actual.manifestSha256,
[string]$baseline.manifestSha256,
[System.StringComparison]::Ordinal
)) "Installer changed external profile root '$($baseline.name)'."
}
}
function Get-HttpStatusCode {
param([Parameter(Mandatory = $true)] [string]$Uri)
try {
return [int](Invoke-WebRequest -Uri $Uri -Method Get -TimeoutSec 5 -UseBasicParsing).StatusCode
} catch {
if ($_.Exception.Response) {
return [int]$_.Exception.Response.StatusCode
}
throw
}
}
function Wait-ForHealth {
param(
[Parameter(Mandatory = $true)] [string]$BaseUrl,
[Parameter(Mandatory = $true)] [int]$TimeoutSeconds
)
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
$lastError = $null
while ([DateTime]::UtcNow -lt $deadline) {
try {
$health = Invoke-JsonRequest "$BaseUrl/health"
if ($health.mode -eq 'local' -and $health.database.healthy -eq $true) {
return $health
}
$lastError = 'Unexpected health payload'
} catch {
$lastError = $_.Exception.Message
}
Start-Sleep -Milliseconds 500
}
throw "Waggle did not become healthy within ${TimeoutSeconds}s. Last error: $lastError"
}
function Get-InstalledProcessIds {
param(
[Parameter(Mandatory = $true)] [string]$AppExecutable,
[Parameter(Mandatory = $true)] [string]$ServiceScript,
[string]$ManagedRuntimeRoot = ''
)
$ids = [System.Collections.Generic.HashSet[int]]::new()
$managedRoot = if ([string]::IsNullOrWhiteSpace($ManagedRuntimeRoot)) {
$null
} else {
[System.IO.Path]::GetFullPath($ManagedRuntimeRoot).TrimEnd('\', '/')
}
$processes = Get-CimInstance Win32_Process -ErrorAction Stop
foreach ($process in $processes) {
$exactApp = $process.ExecutablePath -and
[string]::Equals(
[System.IO.Path]::GetFullPath($process.ExecutablePath),
[System.IO.Path]::GetFullPath($AppExecutable),
[System.StringComparison]::OrdinalIgnoreCase
)
$exactSidecar = $process.CommandLine -and
$process.CommandLine.IndexOf(
$ServiceScript,
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0
$managedRuntime = $managedRoot -and (
($process.ExecutablePath -and
[System.IO.Path]::GetFullPath($process.ExecutablePath).StartsWith(
"$managedRoot\",
[System.StringComparison]::OrdinalIgnoreCase
)) -or
($process.CommandLine -and
$process.CommandLine.IndexOf(
$managedRoot,
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0)
)
if ($exactApp -or $exactSidecar -or $managedRuntime) {
$null = $ids.Add([int]$process.ProcessId)
}
}
return @($ids)
}
function Assert-NoVisibleConsoleDescendant {
param([Parameter(Mandatory = $true)] [int]$RootProcessId)
if ($null -eq ('WaggleInstallerWindowProbe' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class WaggleInstallerWindowProbe
{
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);
}
'@
}
$processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
$descendantIds = [System.Collections.Generic.HashSet[int]]::new()
$frontier = @($RootProcessId)
while ($frontier.Count -gt 0) {
$next = @()
foreach ($process in $processes) {
if ($frontier -contains [int]$process.ParentProcessId -and
$descendantIds.Add([int]$process.ProcessId)) {
$next += [int]$process.ProcessId
}
}
$frontier = $next
}
$consoleHosts = @(
$processes | Where-Object {
$descendantIds.Contains([int]$_.ProcessId) -and [string]::Equals(
[string]$_.Name,
'conhost.exe',
[System.StringComparison]::OrdinalIgnoreCase
)
}
)
$visibleConsoles = @(
$consoleHosts | Where-Object {
$consoleProcessId = [int]$_.ProcessId
$consoleProcess = Get-Process -Id $consoleProcessId -ErrorAction SilentlyContinue
if ($null -eq $consoleProcess) { return $false }
try {
$consoleProcess.Refresh()
$windowHandle = $consoleProcess.MainWindowHandle
return $windowHandle -ne [IntPtr]::Zero -and
[WaggleInstallerWindowProbe]::IsWindowVisible($windowHandle)
} catch {
if ($null -ne (Get-Process -Id $consoleProcessId -ErrorAction SilentlyContinue)) {
throw
}
return $false
}
}
)
Assert-True ($visibleConsoles.Count -eq 0) `
'Installed Waggle spawned a visible console host.'
}
function Wait-ForInstalledRuntimeStop {
param(
[Parameter(Mandatory = $true)] [string]$AppExecutable,
[Parameter(Mandatory = $true)] [string]$ServiceScript,
[Parameter(Mandatory = $true)] [int]$Port,
[string]$ManagedRuntimeRoot = '',
[int[]]$AdditionalPorts = @(),
[ValidateRange(5, 120)] [int]$TimeoutSeconds = 30
)
$ports = @($Port) + @($AdditionalPorts | Where-Object { $_ -ge 1 -and $_ -le 65535 })
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
$consecutiveAvailableProbes = 0
do {
$ownedProcessIds = @(
Get-InstalledProcessIds $AppExecutable $ServiceScript $ManagedRuntimeRoot
)
$busyPorts = @($ports | Where-Object { Test-TcpPortHasListener $_ })
if ($ownedProcessIds.Count -eq 0 -and $busyPorts.Count -eq 0) {
$consecutiveAvailableProbes++
if ($consecutiveAvailableProbes -ge 2) { return }
} else {
$consecutiveAvailableProbes = 0
}
Start-Sleep -Milliseconds 300
} while ([DateTime]::UtcNow -lt $deadline)
$remainingProcessIds = @(
Get-InstalledProcessIds $AppExecutable $ServiceScript $ManagedRuntimeRoot
)
$remainingBusyPorts = @($ports | Where-Object { Test-TcpPortHasListener $_ })
throw "Installed runtime did not stop cleanly; owned PIDs=$($remainingProcessIds -join ',') ports=$($remainingBusyPorts -join ',')"
}
function Assert-NoForeignWaggleProcesses {
param([Parameter(Mandatory = $true)] [string]$ExpectedAppExecutable)
$expectedPath = [System.IO.Path]::GetFullPath($ExpectedAppExecutable)
$foreignProcesses = @(
Get-CimInstance Win32_Process -Filter "Name = 'waggle.exe'" -ErrorAction Stop |
Where-Object {
-not $_.ExecutablePath -or -not [string]::Equals(
[System.IO.Path]::GetFullPath($_.ExecutablePath),
$expectedPath,
[System.StringComparison]::OrdinalIgnoreCase
)
}
)
$details = @($foreignProcesses | ForEach-Object { "$($_.ProcessId):$($_.ExecutablePath)" })
Assert-True ($foreignProcesses.Count -eq 0) `
"A non-certificate Waggle process could be terminated by NSIS: $($details -join ', ')"
}
function Test-RegistryValue {
param(
[Parameter(Mandatory = $true)] [string]$KeyPath,
[Parameter(Mandatory = $true)] [string]$ValueName
)
if (-not (Test-Path -LiteralPath $KeyPath)) { return $false }
$key = Get-Item -LiteralPath $KeyPath
return $key.GetValueNames() -contains $ValueName
}
function Stop-InstalledProcesses {
param(
[Parameter(Mandatory = $true)] [string]$AppExecutable,
[Parameter(Mandatory = $true)] [string]$ServiceScript,
[string]$ManagedRuntimeRoot = ''
)
$taskkill = Join-Path $env:SystemRoot 'System32\taskkill.exe'
foreach ($processId in (Get-InstalledProcessIds $AppExecutable $ServiceScript $ManagedRuntimeRoot)) {
try {
Invoke-RawProcess $taskkill "/PID $processId /T /F" 20
} catch {
# Killing the main process tree can make a separately captured child PID
# disappear. Re-check exact ownership before treating that as a failure.
$stillOwned = Get-InstalledProcessIds $AppExecutable $ServiceScript $ManagedRuntimeRoot
if ($stillOwned -contains $processId) { throw }
}
}
}
function Wait-ForPathState {
param(
[Parameter(Mandatory = $true)] [string]$LiteralPath,
[Parameter(Mandatory = $true)] [bool]$ShouldExist,
[ValidateRange(5, 120)] [int]$TimeoutSeconds = 60
)
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
if ((Test-Path -LiteralPath $LiteralPath) -eq $ShouldExist) { return }
Start-Sleep -Milliseconds 300
}
throw "Path did not reach expected state (exists=$ShouldExist): $LiteralPath"
}
function Get-WaggleShortcutPaths {
$desktop = [Environment]::GetFolderPath('Desktop')
$programs = [Environment]::GetFolderPath('Programs')
return @(
(Join-Path $desktop 'Waggle.lnk'),
(Join-Path $programs 'Waggle.lnk'),
(Join-Path $programs 'Waggle\Waggle.lnk')
)
}
function Assert-CertificateUninstallPostconditions {
param(
[Parameter(Mandatory = $true)] [string]$InstallDir,
[Parameter(Mandatory = $true)] [string]$UninstallRegistry,
[Parameter(Mandatory = $true)] [string]$ProductRegistry,
[Parameter(Mandatory = $true)] [string]$RunRegistry,
[Parameter(Mandatory = $true)] [string]$RunRegistryValue,
[Parameter(Mandatory = $true)] [string[]]$ShortcutPaths,
[Parameter(Mandatory = $true)] [string]$AppExecutable,
[Parameter(Mandatory = $true)] [string]$ServiceScript,
[Parameter(Mandatory = $true)] [int]$Port,
[string]$ManagedRuntimeRoot = '',
[int[]]$AdditionalPorts = @()
)
Wait-ForPathState $InstallDir $false 90
Wait-ForPathState $UninstallRegistry $false 30
Wait-ForPathState $ProductRegistry $false 30
Assert-True (-not (Test-RegistryValue $RunRegistry $RunRegistryValue)) `
'Silent uninstall left or replaced the Waggle autostart entry.'
foreach ($shortcut in $ShortcutPaths) {
Wait-ForPathState $shortcut $false 30
}
Wait-ForInstalledRuntimeStop $AppExecutable $ServiceScript $Port `
-ManagedRuntimeRoot $ManagedRuntimeRoot -AdditionalPorts $AdditionalPorts
Assert-NoForeignWaggleProcesses $AppExecutable
Assert-TcpPortAvailable $Port
}
function Assert-ShortcutTargets {
param(
[Parameter(Mandatory = $true)] [string[]]$ShortcutPaths,
[Parameter(Mandatory = $true)] [string]$ExpectedTarget
)
$shell = New-Object -ComObject WScript.Shell
foreach ($shortcutPath in $ShortcutPaths) {
$shortcut = $shell.CreateShortcut($shortcutPath)
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath($shortcut.TargetPath),
[System.IO.Path]::GetFullPath($ExpectedTarget),
[System.StringComparison]::OrdinalIgnoreCase
)
) "Shortcut does not target the installed Waggle executable: $shortcutPath"
}
}
function Assert-SafeReceiptPath {
param([Parameter(Mandatory = $true)] [string]$ReceiptPath)
$resolvedReceipt = [System.IO.Path]::GetFullPath($ReceiptPath)
$existingReceipt = Get-Item -LiteralPath $resolvedReceipt -Force -ErrorAction SilentlyContinue
Assert-True ($null -eq $existingReceipt) `
"Receipt path already exists; refusing to overwrite: $resolvedReceipt"
$parent = Split-Path -Parent $resolvedReceipt
Assert-True (-not [string]::IsNullOrWhiteSpace($parent)) `
"Receipt path has no parent directory: $resolvedReceipt"
$resolvedParent = [System.IO.Path]::GetFullPath($parent).TrimEnd('\')
Assert-True (Test-Path -LiteralPath $resolvedParent -PathType Container) `
"Receipt parent directory must already exist: $resolvedParent"
$cursor = Get-Item -LiteralPath $resolvedParent -Force
while ($null -ne $cursor) {
Assert-True ($cursor -is [System.IO.DirectoryInfo]) `
"Receipt parent is not a directory: $($cursor.FullName)"
Assert-True (($cursor.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Receipt parent must not be a reparse point: $($cursor.FullName)"
$cursor = $cursor.Parent
}
}
function Reserve-CertificateReceiptPath {
param([Parameter(Mandatory = $true)] [string]$ReceiptPath)
Assert-SafeReceiptPath $ReceiptPath
return [System.IO.File]::Open(
$ReceiptPath,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
}
function Write-CertificateReceipt {
param(
[Parameter(Mandatory = $true)] [System.IO.FileStream]$Reservation,
[Parameter(Mandatory = $true)] [string]$Content
)
$bytes = [System.Text.UTF8Encoding]::new($false).GetBytes($Content)
try {
Assert-True ($Reservation.CanWrite -and $Reservation.Length -eq 0) `
'Certificate receipt reservation is not exclusively writable and empty.'
$Reservation.Write($bytes, 0, $bytes.Length)
$Reservation.Flush($true)
} finally {
$Reservation.Dispose()
}
}
function Assert-SafeScratchRoot {
param(
[Parameter(Mandatory = $true)] [string]$ScratchRoot,
[Parameter(Mandatory = $true)] [string]$RunId
)
$resolved = [System.IO.Path]::GetFullPath($ScratchRoot).TrimEnd('\')
$expected = [System.IO.Path]::GetFullPath(
(Join-Path ([System.IO.Path]::GetTempPath()) "waggle-installer-cert-$RunId")
).TrimEnd('\')
Assert-True ([string]::Equals(
$resolved,
$expected,
[System.StringComparison]::OrdinalIgnoreCase
)) "Scratch root is not the exact temp root for certificate run ${RunId}: $resolved"
}
function Assert-CertificateScratchOwnership {
param(
[Parameter(Mandatory = $true)] [string]$ScratchRoot,
[Parameter(Mandatory = $true)] [string]$OwnershipMarker,
[Parameter(Mandatory = $true)] [string]$RunId,
[switch]$RequireOnlyMarker
)
$resolvedRoot = [System.IO.Path]::GetFullPath($ScratchRoot).TrimEnd('\')
Assert-SafeScratchRoot $resolvedRoot $RunId
$rootItem = Get-Item -LiteralPath $resolvedRoot -Force
Assert-True ($rootItem.PSIsContainer -and (
$rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "Scratch root is missing or is a reparse point: $resolvedRoot"
$expectedMarker = Join-Path $resolvedRoot ".waggle-installer-certificate-owner-$RunId"
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath($OwnershipMarker),
$expectedMarker,
[System.StringComparison]::OrdinalIgnoreCase
)) "Scratch ownership marker has an unexpected path: $OwnershipMarker"
$markerItem = Get-Item -LiteralPath $expectedMarker -Force
Assert-True (-not $markerItem.PSIsContainer -and (
$markerItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "Scratch ownership marker is missing or is a reparse point: $expectedMarker"
Assert-True ((Get-Content -Raw -LiteralPath $expectedMarker) -ceq $RunId) `
"Scratch ownership marker does not match certificate run ${RunId}."
$manifest = @(Get-CertificateDataManifest $resolvedRoot -SkipHashes)
if ($RequireOnlyMarker) {
Assert-True (
$manifest.Count -eq 1 -and
[string]$manifest[0].path -ceq (Split-Path -Leaf $expectedMarker)
) "Scratch root changed before ownership was established: $resolvedRoot"
}
}
function New-CertificateScratchRoot {
param(
[Parameter(Mandatory = $true)] [string]$ScratchRoot,
[Parameter(Mandatory = $true)] [string]$RunId
)
$resolvedRoot = [System.IO.Path]::GetFullPath($ScratchRoot).TrimEnd('\')
Assert-SafeScratchRoot $resolvedRoot $RunId
Assert-True (-not (Test-Path -LiteralPath $resolvedRoot)) `
"Scratch root already exists; refusing to adopt it: $resolvedRoot"
$createdRoot = New-Item -ItemType Directory -Path $resolvedRoot -ErrorAction Stop
Assert-True ($createdRoot.PSIsContainer -and (
$createdRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "New scratch root is not a normal directory: $resolvedRoot"
$ownershipMarker = Join-Path $resolvedRoot ".waggle-installer-certificate-owner-$RunId"
$markerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($RunId)
$markerStream = [System.IO.File]::Open(
$ownershipMarker,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
try {
$markerStream.Write($markerBytes, 0, $markerBytes.Length)
$markerStream.Flush($true)
} finally {
$markerStream.Dispose()
}
Assert-CertificateScratchOwnership $resolvedRoot $ownershipMarker $RunId -RequireOnlyMarker
return $ownershipMarker
}
function Remove-CertificateScratchRoot {
param(
[Parameter(Mandatory = $true)] [string]$ScratchRoot,
[Parameter(Mandatory = $true)] [string]$OwnershipMarker,
[Parameter(Mandatory = $true)] [string]$RunId
)
$resolvedRoot = [System.IO.Path]::GetFullPath($ScratchRoot).TrimEnd('\')
Assert-CertificateScratchOwnership $resolvedRoot $OwnershipMarker $RunId
$manifest = @(Get-CertificateDataManifest $resolvedRoot -SkipHashes)
$markerRelativePath = Get-CertificateRelativePath $resolvedRoot $OwnershipMarker
foreach ($entry in @($manifest | Where-Object {
$_.type -eq 'file' -and -not [string]::Equals(
[string]$_.path,
$markerRelativePath,
[System.StringComparison]::OrdinalIgnoreCase
)
})) {
$entryPath = Join-Path $resolvedRoot ([string]$entry.path).Replace('/', '\')
$item = Get-Item -LiteralPath $entryPath -Force -ErrorAction Stop
Assert-True (-not $item.PSIsContainer -and (
$item.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "Refusing to remove replaced scratch file: $entryPath"
Remove-Item -LiteralPath $entryPath -Force
}
$directories = @($manifest | Where-Object {
$_.type -eq 'directory'
} | Sort-Object { ([string]$_.path).Length } -Descending)
foreach ($entry in $directories) {
$entryPath = Join-Path $resolvedRoot ([string]$entry.path).Replace('/', '\')
$item = Get-Item -LiteralPath $entryPath -Force -ErrorAction Stop
Assert-True ($item.PSIsContainer -and (
$item.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "Refusing to remove replaced scratch directory: $entryPath"
Assert-True (@(Get-ChildItem -LiteralPath $entryPath -Force).Count -eq 0) `
"Certificate scratch directory changed during cleanup: $entryPath"
Remove-Item -LiteralPath $entryPath -Force
}
$remaining = @(Get-ChildItem -LiteralPath $resolvedRoot -Force)
Assert-True (
$remaining.Count -eq 1 -and
[string]::Equals(
$remaining[0].FullName,
[System.IO.Path]::GetFullPath($OwnershipMarker),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Certificate scratch changed before ownership-marker cleanup.'
Assert-CertificateScratchOwnership $resolvedRoot $OwnershipMarker $RunId -RequireOnlyMarker
Remove-Item -LiteralPath $OwnershipMarker -Force
$rootItem = Get-Item -LiteralPath $resolvedRoot -Force -ErrorAction Stop
Assert-True ($rootItem.PSIsContainer -and (
$rootItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint
) -eq 0) "Refusing to remove replaced scratch root: $resolvedRoot"
Assert-True (@(Get-ChildItem -LiteralPath $resolvedRoot -Force).Count -eq 0) `
"Certificate scratch root changed during cleanup: $resolvedRoot"
Remove-Item -LiteralPath $resolvedRoot -Force
Assert-True (-not (Test-Path -LiteralPath $resolvedRoot)) `
"Certificate scratch cleanup did not remove the owned root: $resolvedRoot"
}
function Remove-CertificateProductRegistry {
param(
[Parameter(Mandatory = $true)] [string]$ProductRegistry,
[Parameter(Mandatory = $true)] [string]$ExpectedInstallDir
)
if (-not (Test-Path -LiteralPath $ProductRegistry)) { return }
$item = Get-Item -LiteralPath $ProductRegistry
$storedInstallDir = [string]$item.GetValue('')
Assert-True (-not [string]::IsNullOrWhiteSpace($storedInstallDir)) `
"Refusing to remove product registry key without an owned install path: $ProductRegistry"
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath($storedInstallDir.Trim('"')),
[System.IO.Path]::GetFullPath($ExpectedInstallDir),
[System.StringComparison]::OrdinalIgnoreCase
)
) "Refusing to remove product registry key owned by another install: $storedInstallDir"
Remove-Item -LiteralPath $ProductRegistry -Recurse -Force
}
function Clear-AbandonedCertificateProductRegistry {
param(
[Parameter(Mandatory = $true)] [string]$ProductRegistry,
[Parameter(Mandatory = $true)] [string]$UninstallRegistry
)
if (-not (Test-Path -LiteralPath $ProductRegistry)) { return }
Assert-True (-not (Test-Path -LiteralPath $UninstallRegistry)) `
'Refusing to remove product metadata while Waggle is registered.'
$item = Get-Item -LiteralPath $ProductRegistry
$storedInstallDir = [string]$item.GetValue('')
Assert-True (-not [string]::IsNullOrWhiteSpace($storedInstallDir)) `
"Refusing to remove product registry key without an install path: $ProductRegistry"
$resolvedInstallDir = [System.IO.Path]::GetFullPath($storedInstallDir.Trim('"')).TrimEnd('\')
$certificateRoot = Split-Path -Parent $resolvedInstallDir
$tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\'
Assert-True ($resolvedInstallDir.StartsWith($tempRoot, [System.StringComparison]::OrdinalIgnoreCase)) `
"Refusing to remove product metadata outside the system temp directory: $resolvedInstallDir"
Assert-True ((Split-Path -Leaf $resolvedInstallDir) -eq 'install') `
"Refusing to remove product metadata for an unexpected install directory: $resolvedInstallDir"
Assert-True ((Split-Path -Leaf $certificateRoot).StartsWith('waggle-installer-cert-', [System.StringComparison]::Ordinal)) `
"Refusing to remove product metadata not owned by the installer certificate: $resolvedInstallDir"
Assert-True (-not (Test-Path -LiteralPath $resolvedInstallDir)) `
"Refusing to remove product metadata for an install directory that still exists: $resolvedInstallDir"
Remove-CertificateProductRegistry $ProductRegistry $resolvedInstallDir
}
function Remove-CertificateProfileData {
param(
[Parameter(Mandatory = $true)] [string]$ProfileDataDir,
[Parameter(Mandatory = $true)] [string]$ProfileDataMarker,
[Parameter(Mandatory = $true)] [string]$RunId,
[Parameter(Mandatory = $true)] [bool]$ProfileAbsenceProven,
[Parameter(Mandatory = $true)] [bool]$RuntimeConfirmedStopped
)
Assert-True $ProfileAbsenceProven `
'Refusing to clean a profile whose pre-certificate absence was not proven.'
Assert-True $RuntimeConfirmedStopped `
'Refusing to clean the certificate profile before runtime shutdown is proven.'
$expectedDataDir = [System.IO.Path]::GetFullPath((Join-Path $env:USERPROFILE '.waggle')).TrimEnd('\')
$resolvedDataDir = [System.IO.Path]::GetFullPath($ProfileDataDir).TrimEnd('\')
Assert-True (
[string]::Equals($resolvedDataDir, $expectedDataDir, [System.StringComparison]::OrdinalIgnoreCase)
) "Refusing to clean an unexpected profile directory: $resolvedDataDir"
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath((Split-Path -Parent $ProfileDataMarker)).TrimEnd('\'),
$resolvedDataDir,
[System.StringComparison]::OrdinalIgnoreCase
)
) "Refusing to clean a profile marker outside the certificate directory: $ProfileDataMarker"
Assert-True ((Split-Path -Leaf $ProfileDataMarker) -eq "installer-certificate-profile-marker-$RunId.txt") `
"Refusing to clean an unexpected profile marker: $ProfileDataMarker"
Assert-True (Test-Path -LiteralPath $ProfileDataMarker -PathType Leaf) `
"Certificate ownership marker is missing: $ProfileDataMarker"
Assert-True ((Get-Content -Raw -LiteralPath $ProfileDataMarker).Trim() -eq $RunId) `
"Refusing to remove a profile marker not owned by this certificate: $ProfileDataMarker"
$entries = @(Get-CertificateDataManifest $resolvedDataDir -SkipHashes)
$markerRelativePath = Get-CertificateRelativePath $resolvedDataDir $ProfileDataMarker
foreach ($entry in @($entries | Where-Object {
$_.type -eq 'file' -and -not [string]::Equals(
[string]$_.path,
$markerRelativePath,
[System.StringComparison]::OrdinalIgnoreCase
)
})) {
$entryPath = Join-Path $resolvedDataDir ([string]$entry.path).Replace('/', '\')
$item = Get-Item -LiteralPath $entryPath -Force
Assert-True (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Refusing to remove a replaced profile entry: $entryPath"
Remove-Item -LiteralPath $entryPath -Force
}
$directories = @($entries | Where-Object { $_.type -eq 'directory' } | Sort-Object {
([string]$_.path).Length
} -Descending)
foreach ($entry in $directories) {
$entryPath = Join-Path $resolvedDataDir ([string]$entry.path).Replace('/', '\')
$item = Get-Item -LiteralPath $entryPath -Force
Assert-True (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
"Refusing to remove a replaced profile directory: $entryPath"
Assert-True (@(Get-ChildItem -LiteralPath $entryPath -Force).Count -eq 0) `
"Certificate profile directory changed during cleanup: $entryPath"
Remove-Item -LiteralPath $entryPath -Force
}
$remaining = @(Get-ChildItem -LiteralPath $resolvedDataDir -Force)
Assert-True (
$remaining.Count -eq 1 -and
[string]::Equals(
$remaining[0].FullName,
[System.IO.Path]::GetFullPath($ProfileDataMarker),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Certificate profile changed before ownership-marker cleanup.'
Remove-Item -LiteralPath $ProfileDataMarker -Force
Remove-Item -LiteralPath $resolvedDataDir -Force
Assert-True (-not (Test-Path -LiteralPath $resolvedDataDir)) `
'Certificate profile cleanup did not remove the owned profile root.'
}
$hasExpectedSignerThumbprint = -not [string]::IsNullOrWhiteSpace($ExpectedSignerThumbprint)
$hasExpectedSignerSubject = -not [string]::IsNullOrWhiteSpace($ExpectedSignerSubject)
if ($RequireAuthenticodeSignature) {
Assert-True ($hasExpectedSignerThumbprint -xor $hasExpectedSignerSubject) `
'Exactly one expected signer identity binding is required when Authenticode is required.'
if ($hasExpectedSignerThumbprint) {
$normalizedExpectedSignerThumbprint = (
$ExpectedSignerThumbprint -replace '\s', ''
).ToUpperInvariant()
Assert-True ($normalizedExpectedSignerThumbprint -match '^[0-9A-F]{40}$') `
'Expected signer thumbprint must be exactly 40 hexadecimal characters.'
}
}
$installer = Get-Item -LiteralPath $InstallerPath
Assert-True (-not $installer.PSIsContainer) 'InstallerPath must be a file'
Assert-True ($installer.Extension -eq '.exe') 'InstallerPath must be an NSIS .exe'
$InstallerPath = $installer.FullName
$candidateInstallerHash = (Get-FileHash -LiteralPath $InstallerPath -Algorithm SHA256).Hash
$previousInstaller = $null
$previousInstallerSignature = $null
$previousInstallerEvidence = $null
$previousVersionObject = $null
$candidateVersionObject = $null
$upgradeInputs = @(
$PreviousInstallerPath,
$ExpectedPreviousInstallerSha256,
$ExpectedPreviousVersion,
$ExpectedPreviousSourceRevision,
$ExpectedCandidateInstallerSha256,
$ExpectedCandidateVersion
)
$hasUpgradeInputs = @(
$upgradeInputs | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
).Count -gt 0
Assert-True ($RequireVersionToVersionUpgrade -or -not $hasUpgradeInputs) `
'Version-to-version inputs require RequireVersionToVersionUpgrade.'
if ($RequireVersionToVersionUpgrade) {
Assert-True $RequireAuthenticodeSignature `
'Version-to-version certification requires Authenticode signature enforcement.'
Assert-True ($hasUpgradeInputs -and @(
$upgradeInputs | Where-Object { [string]::IsNullOrWhiteSpace([string]$_) }
).Count -eq 0) 'Version-to-version certification requires every previous and candidate input.'
Assert-True ($ExpectedPreviousInstallerSha256 -match '^[0-9A-Fa-f]{64}$') `
'Previous installer SHA-256 must be exactly 64 hexadecimal characters.'
Assert-True ($ExpectedCandidateInstallerSha256 -match '^[0-9A-Fa-f]{64}$') `
'Candidate installer SHA-256 must be exactly 64 hexadecimal characters.'
Assert-True ($ExpectedPreviousSourceRevision -match '^[0-9A-Fa-f]{40}$') `
'Previous source revision must be exactly 40 hexadecimal characters.'
Assert-True ([string]::Equals(
$candidateInstallerHash,
$ExpectedCandidateInstallerSha256,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Candidate installer does not match the pre-certification SHA-256.'
$previousVersionObject = ConvertTo-StrictSemanticVersion `
$ExpectedPreviousVersion 'Previous version'
$candidateVersionObject = ConvertTo-StrictSemanticVersion `
$ExpectedCandidateVersion 'Candidate version'
Assert-True ($candidateVersionObject -gt $previousVersionObject) `
'Candidate version must be newer than the previous version.'
$previousInstaller = Get-Item -LiteralPath $PreviousInstallerPath
Assert-True (-not $previousInstaller.PSIsContainer) 'PreviousInstallerPath must be a file.'
Assert-True ($previousInstaller.Extension -eq '.exe') `
'PreviousInstallerPath must be an NSIS .exe.'
$PreviousInstallerPath = $previousInstaller.FullName
Assert-True (-not [string]::Equals(
$PreviousInstallerPath,
$InstallerPath,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Previous installer and candidate installer must be distinct files.'
$previousInstallerHash = (
Get-FileHash -LiteralPath $PreviousInstallerPath -Algorithm SHA256
).Hash
Assert-True ([string]::Equals(
$previousInstallerHash,
$ExpectedPreviousInstallerSha256,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Previous installer does not match the protected SHA-256.'
$previousInstallerSignature = Get-AuthenticodeSignature -LiteralPath $PreviousInstallerPath
Assert-ExpectedAuthenticodeSignature `
$previousInstallerSignature `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Previous release installer'
$previousInstallerEvidence = New-AuthenticodeArtifactReceipt `
$previousInstaller $previousInstallerSignature
$previousInstallerEvidence['expectedVersion'] = $ExpectedPreviousVersion
}
if (-not $ReceiptPath) {
$ReceiptPath = Join-Path $installer.DirectoryName 'windows-installer-certificate.json'
}
$ReceiptPath = [System.IO.Path]::GetFullPath($ReceiptPath)
$installerHookPath = [System.IO.Path]::GetFullPath(
(Join-Path $PSScriptRoot '..\app\src-tauri\nsis\installer.nsi')
)
$runId = [Guid]::NewGuid().ToString('N')
$scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) "waggle-installer-cert-$runId"
Assert-SafeScratchRoot $scratchRoot $runId
$installDir = Join-Path $scratchRoot 'install'
$profileDataDir = Join-Path $env:USERPROFILE '.waggle'
$externalProfileRootTargets = @(
[ordered]@{ name = '.hive-mind'; path = (Join-Path $env:USERPROFILE '.hive-mind') }
[ordered]@{ name = '.ollama'; path = (Join-Path $env:USERPROFILE '.ollama') }
)
$dataDir = $profileDataDir
$appExecutable = Join-Path $installDir 'waggle.exe'
$serviceScript = Join-Path $installDir 'resources\service.js'
$packagedServiceScript = [System.IO.Path]::GetFullPath(
(Join-Path $PSScriptRoot '..\app\src-tauri\resources\service.js')
)
$canonicalMarketplaceDb = [System.IO.Path]::GetFullPath(
(Join-Path $PSScriptRoot '..\packages\marketplace\marketplace.db')
)
$installedMarketplaceDb = Join-Path $installDir 'resources\marketplace.db'
$writableMarketplaceDb = Join-Path $dataDir 'marketplace.db'
$bundledNode = Join-Path $installDir 'resources\node.exe'
$bundledNpmRuntime = Join-Path $installDir 'resources\node_modules\waggle-node-runtime'
$bundledNpmCli = Join-Path $bundledNpmRuntime 'node_modules\npm\bin\npm-cli.js'
$bundledNpxCli = Join-Path $bundledNpmRuntime 'node_modules\npm\bin\npx-cli.js'
$bundledNpmWrapper = Join-Path $bundledNpmRuntime 'bin\npm.cmd'
$bundledNpxWrapper = Join-Path $bundledNpmRuntime 'bin\npx.cmd'
$uninstaller = Join-Path $installDir 'uninstall.exe'
$dataMarker = Join-Path $dataDir 'installer-certificate-marker.txt'
$profileDataMarker = Join-Path $profileDataDir "installer-certificate-profile-marker-$runId.txt"
$uninstallRegistry = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Waggle'
$productRegistry = 'HKCU:\Software\egzakta\Waggle'
$runRegistry = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
$runRegistryValue = 'Waggle'
$shortcutCandidates = Get-WaggleShortcutPaths
$startedAt = [DateTime]::UtcNow
$installerStarted = $false
$profileAbsenceProven = $false
$externalProfileRootsPreProven = $false
$externalProfileRootBaselines = @()
$profileRootOwned = $false
$runtimeConfirmedStopped = $false
$certificateLifecycleData = $null
$preUninstallDataManifest = @()
$installedShortcuts = @()
$uninstallPostconditionsConfirmed = $false
$environmentNamesToClear = @(
'WAGGLE_NODE_PATH', 'WAGGLE_DATA_DIR', 'NODE_PATH', 'NODE_OPTIONS', 'DOCKER_HOST',
'WAGGLE_TRUST_LOCALHOST', 'WAGGLE_SQLITE_VEC_PATH', 'ONNXRUNTIME_NODE_BINDING_PATH',
'EMBEDDING_PROVIDER', 'EMBEDDING_MODEL', 'OLLAMA_EMBED_MODEL',
'WAGGLE_EMBEDDING_PROVIDER', 'HIVE_MIND_EMBEDDING_PROVIDER',
'VOYAGE_API_KEY', 'WAGGLE_VOYAGE_API_KEY', 'WAGGLE_EVAL_MODE',
'WAGGLE_SUPPRESS_EMBEDDING_WARNING', 'WAGGLE_LITELLM_URL',
'WAGGLE_NPM_LIFECYCLE_WITNESS_PREFIX',
'OLLAMA_MODELS', 'HF_HOME', 'HF_HUB_CACHE', 'TRANSFORMERS_CACHE', 'XDG_CACHE_HOME',
'LITELLM_API_KEY', 'LITELLM_MASTER_KEY',
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY',
'GOOGLE_API_KEY', 'XAI_API_KEY', 'DEEPSEEK_API_KEY',
'MISTRAL_API_KEY', 'DASHSCOPE_API_KEY', 'MINIMAX_API_KEY',
'ZHIPU_API_KEY', 'MOONSHOT_API_KEY', 'PERPLEXITY_API_KEY',
'OPENROUTER_API_KEY'
)
$environmentNamesToClear += @(
Get-ChildItem Env: |
Where-Object { $_.Name -match '(?i)^npm_' } |
ForEach-Object { $_.Name }
)
$environmentNamesToClear = @($environmentNamesToClear | Sort-Object -Unique)
$isolatedEnvironmentNames = @(
'PATH', 'WAGGLE_PORT', 'WAGGLE_DATA_DIR', 'WAGGLE_HOST',
'OLLAMA_HOST', 'VLLM_HOST', 'WAGGLE_SKIP_MARKETPLACE_SYNC'
) + $environmentNamesToClear
$environmentSnapshot = @{}
foreach ($name in $isolatedEnvironmentNames) {
$environmentSnapshot[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
}
$receipt = [ordered]@{
schemaVersion = 4
certificationMode = if ($RequireVersionToVersionUpgrade) {
'version-to-version-upgrade'
} else {
'same-version-repair'
}
status = 'running'
startedAt = $startedAt.ToString('o')
finishedAt = $null
installer = [ordered]@{
name = $installer.Name
sha256 = $candidateInstallerHash
sizeBytes = $installer.Length
authenticodeStatus = $null
signatureType = $null
signerSubject = $null
signerThumbprint = $null
timestampAuthoritySubject = $null
timestampAuthorityThumbprint = $null
timestampAuthorityNotBefore = $null
timestampAuthorityNotAfter = $null
}
previousInstaller = $previousInstallerEvidence
previousInstalledApp = $null
installedApp = $null
platform = [ordered]@{
os = [Environment]::OSVersion.VersionString
architecture = $env:PROCESSOR_ARCHITECTURE
powershell = $PSVersionTable.PSVersion.ToString()
}
evidence = [ordered]@{
certificateRunId = $runId
sourceRevision = $null
workflowRunId = [Environment]::GetEnvironmentVariable('GITHUB_RUN_ID', 'Process')
workflowRunAttempt = [Environment]::GetEnvironmentVariable('GITHUB_RUN_ATTEMPT', 'Process')
certifierSha256 = $null
installerHookSha256 = $null
generatedInstallerScriptSha256 = $null
generatedInstallerHookPath = $null
sidecarSourceRevision = $null
sidecarBundleSha256 = $null
sidecarProvenanceSha256 = $null
sidecarSourceInputCount = 0
windowsInboxTools = [ordered]@{}
}
scratchRoot = $scratchRoot
embeddingPayloadReady = $false
embeddingPayload = $null
certifiedTier = $null
managedModelVerified = $false
managedModelDigest = $null
lifecycleData = $null
bundledNpm = $null
upgrade = if ($RequireVersionToVersionUpgrade) {
[ordered]@{
previousVersion = $ExpectedPreviousVersion
candidateVersion = $ExpectedCandidateVersion
previousSourceRevision = $ExpectedPreviousSourceRevision.ToLowerInvariant()
installDirectory = $installDir
observedPreviousVersion = $null
observedCandidateVersion = $null
configuredDataMarkerSha256 = $null
profileMarkerSha256 = $null
vaultKeySha256 = $null
managedModelName = $null
managedModelDigest = $null
}
} else {
$null
}
checks = [ordered]@{}
error = $null
}
if ($RequireVersionToVersionUpgrade) {
$receipt.checks['previousInstallerHash'] = $true
$receipt.checks['previousInstallerAuthenticodeSignature'] = $true
$receipt.checks['previousInstallerAuthenticodeSigner'] = $true
$receipt.checks['previousInstallerAuthenticodeTimestamp'] = $true
$receipt.checks['candidateInstallerHash'] = $true
$receipt.checks['versionOrder'] = $true
}
$receiptReservation = Reserve-CertificateReceiptPath $ReceiptPath
$scratchOwnershipMarker = $null
$ollamaPort = 0
Assert-True (
$WebViewDebugPort -eq 0 -or
($WebViewDebugPort -ge 1024 -and $WebViewDebugPort -le 65535)
) 'WebViewDebugPort must be 0 or an integer between 1024 and 65535.'
$managedRuntimeRoot = Join-Path $dataDir 'runtimes\ollama'
$managedCertificateModel = 'qwen2.5:0.5b'
$managedOperationTimeoutSeconds = 3600
try {
$scratchOwnershipMarker = New-CertificateScratchRoot $scratchRoot $runId
Assert-True (Test-Path -LiteralPath $installerHookPath -PathType Leaf) `
'Configured NSIS installer hook is missing.'
$installerHookFile = Get-Item -LiteralPath $installerHookPath
$installerHook = Get-Content -Raw -LiteralPath $installerHookPath
Assert-True (-not ($installerHook -match '(?im)^\s*!macro\s+NSIS_HOOK_POSTUNINSTALL\b')) `
'Custom post-uninstall behavior could affect profile data.'
Assert-True ($installerHook -match '(?im)^\s*!macro\s+NSIS_HOOK_PREUNINSTALL\b') `
'Custom pre-uninstall data-preservation hook is missing.'
Assert-True ($installerHook -match '(?im)^\s*StrCpy\s+\$DeleteAppDataCheckboxState\s+0\s*$') `
'Custom pre-uninstall hook does not neutralize Tauri app-data deletion.'
Assert-True (-not (
$installerHook -match '(?im)^\s*(?:RMDir|Delete)\b[^\r\n]*\$PROFILE[\\/]+\.waggle(?:[\\/"\s]|$)'
)) 'NSIS hook contains a destructive Waggle-data operation.'
$nodeCommand = Get-Command node -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
Assert-True ($null -ne $nodeCommand) 'Node.js is required to reproduce the sidecar bundle.'
$bootstrapHelperPath = Join-Path $PSScriptRoot 'read-tauri-bootstrap-token.mjs'
Assert-True (Test-Path -LiteralPath $bootstrapHelperPath -PathType Leaf) `
'Tauri bootstrap helper is missing.'
Assert-True (Test-Path -LiteralPath $packagedServiceScript -PathType Leaf) `
'Packaged resources/service.js is missing before certification.'
$sidecarHashBeforeRebuild = (
Get-FileHash -LiteralPath $packagedServiceScript -Algorithm SHA256
).Hash
& $nodeCommand.Source (Join-Path $PSScriptRoot 'build-sidecar.mjs')
Assert-True ($LASTEXITCODE -eq 0) 'Could not reproduce packaged resources/service.js.'
$sidecarHashAfterRebuild = (
Get-FileHash -LiteralPath $packagedServiceScript -Algorithm SHA256
).Hash
Assert-True (
[string]::Equals(
$sidecarHashBeforeRebuild,
$sidecarHashAfterRebuild,
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Packaged resources/service.js does not match a clean sidecar rebuild.'
$packagedSidecarProvenance = Get-SidecarProvenance $packagedServiceScript
& $nodeCommand.Source `
(Join-Path $PSScriptRoot 'check-sidecar-resources.mjs') `
--expected-source-revision `
([string]$packagedSidecarProvenance.manifest.sourceRevision)
Assert-True ($LASTEXITCODE -eq 0) 'Packaged sidecar resources failed provenance preflight.'
if ($RequireAuthenticodeSignature) {
Assert-True (-not [string]::IsNullOrWhiteSpace($ExpectedSourceRevision)) `
'Release certification requires the expected source revision.'
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedSourceRevision)) {
$normalizedExpectedSourceRevision = $ExpectedSourceRevision.Trim().ToLowerInvariant()
Assert-True ($normalizedExpectedSourceRevision -match '^[0-9a-f]{40}$') `
'Expected source revision must be exactly 40 hexadecimal characters.'
$gitCommand = Get-Command git -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
Assert-True ($null -ne $gitCommand) 'git is required to bind the installer receipt to source.'
$repositoryRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$revisionOutput = @(& $gitCommand.Source -C $repositoryRoot rev-parse HEAD 2>$null)
Assert-True ($LASTEXITCODE -eq 0 -and $revisionOutput.Count -eq 1) `
'Could not resolve the repository source revision.'
$repositoryRevision = ([string]$revisionOutput[0]).Trim().ToLowerInvariant()
Assert-True ($repositoryRevision -eq $normalizedExpectedSourceRevision) `
"Repository revision $repositoryRevision does not match expected source $normalizedExpectedSourceRevision."
Assert-CleanRepositoryWorktree `
-GitExecutable $gitCommand.Source `
-RepositoryRoot $repositoryRoot
$receipt.evidence.sourceRevision = $repositoryRevision
$receipt.checks['sourceRevision'] = $true
$receipt.checks['sourceFilesClean'] = $true
Assert-SidecarSourceBinding `
-Provenance $packagedSidecarProvenance `
-RepositoryRoot $repositoryRoot `
-ExpectedRevision $repositoryRevision `
-GitExecutable $gitCommand.Source
} else {
$receipt.evidence.sourceRevision = [Environment]::GetEnvironmentVariable('GITHUB_SHA', 'Process')
}
if ([string]::IsNullOrWhiteSpace([string]$receipt.evidence.sourceRevision)) {
$receipt.evidence.sourceRevision = [string]$packagedSidecarProvenance.manifest.sourceRevision
}
$receipt.evidence.sidecarSourceRevision =
[string]$packagedSidecarProvenance.manifest.sourceRevision
$receipt.evidence.sidecarBundleSha256 = [string]$packagedSidecarProvenance.bundleSha256
$receipt.evidence.sidecarProvenanceSha256 =
[string]$packagedSidecarProvenance.provenanceSha256
$receipt.evidence.sidecarSourceInputCount =
[int]$packagedSidecarProvenance.sourceInputCount
$receipt.evidence.certifierSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash
$receipt.evidence.installerHookSha256 = (Get-FileHash -LiteralPath $installerHookPath -Algorithm SHA256).Hash
$releaseDirectory = Split-Path -Parent (Split-Path -Parent $installer.DirectoryName)
$generatedInstallerScripts = @(
Get-ChildItem -LiteralPath (Join-Path $releaseDirectory 'nsis') -Recurse -Filter 'installer.nsi' -File
)
Assert-True ($generatedInstallerScripts.Count -eq 1) `
"Expected exactly one generated NSIS script, found $($generatedInstallerScripts.Count)"
$generatedInstallerScript = $generatedInstallerScripts[0]
Assert-True ($generatedInstallerScript.LastWriteTimeUtc -ge $installerHookFile.LastWriteTimeUtc) `
'Generated NSIS source predates the configured installer hook; the build is stale.'
Assert-True ($installer.LastWriteTimeUtc -ge $installerHookFile.LastWriteTimeUtc) `
'Setup executable predates the configured installer hook; the artifact is stale.'
Assert-True ($installer.LastWriteTimeUtc -ge $generatedInstallerScript.LastWriteTimeUtc) `
'Generated NSIS source is newer than the setup executable; the artifact is stale.'
$generatedInstallerContent = Get-Content -Raw -LiteralPath $generatedInstallerScript.FullName
$matchingHookIncludes = 0
foreach ($includeMatch in [regex]::Matches(
$generatedInstallerContent,
'(?im)^\s*!include\s+"(?<path>[^"]+)"\s*$'
)) {
try {
$resolvedIncludePath = [System.IO.Path]::GetFullPath($includeMatch.Groups['path'].Value)
} catch {
continue
}
if ([string]::Equals(
$resolvedIncludePath,
$installerHookFile.FullName,
[System.StringComparison]::OrdinalIgnoreCase
)) {
$matchingHookIncludes++
}
}
Assert-True ($matchingHookIncludes -eq 1) `
"Generated NSIS source must include the exact configured hook once; found $matchingHookIncludes matches."
$receipt.evidence.generatedInstallerScriptSha256 = (
Get-FileHash -LiteralPath $generatedInstallerScript.FullName -Algorithm SHA256
).Hash
$receipt.evidence.generatedInstallerHookPath = $installerHookFile.FullName
$receipt.checks['generatedInstallerSource'] = $true
$receipt.checks['generatedInstallerInclude'] = $true
$receipt.checks['profileDataDeletionAbsent'] = $true
$receipt.checks['baseAppDataDeletionNeutralized'] = $true
$signature = Get-AuthenticodeSignature -LiteralPath $InstallerPath
$receipt.installer.authenticodeStatus = [string]$signature.Status
$receipt.installer.signatureType = [string]$signature.SignatureType
if ($signature.SignerCertificate) {
$receipt.installer.signerSubject = $signature.SignerCertificate.Subject
$receipt.installer.signerThumbprint = $signature.SignerCertificate.Thumbprint
}
if ($signature.TimeStamperCertificate) {
$receipt.installer.timestampAuthoritySubject = $signature.TimeStamperCertificate.Subject
$receipt.installer.timestampAuthorityThumbprint = $signature.TimeStamperCertificate.Thumbprint
$receipt.installer.timestampAuthorityNotBefore = $signature.TimeStamperCertificate.NotBefore.ToUniversalTime().ToString('o')
$receipt.installer.timestampAuthorityNotAfter = $signature.TimeStamperCertificate.NotAfter.ToUniversalTime().ToString('o')
}
if ($RequireAuthenticodeSignature) {
Assert-ExpectedAuthenticodeSignature `
$signature $ExpectedSignerThumbprint $ExpectedSignerSubject 'Release installer'
$receipt.checks['authenticodeSignature'] = $true
$receipt.checks['authenticodeSigner'] = $true
$receipt.checks['authenticodeTimestamp'] = $true
}
Clear-AbandonedCertificateProductRegistry $productRegistry $uninstallRegistry
Assert-True (-not (Test-Path -LiteralPath $uninstallRegistry)) `
'A current-user Waggle installation is already registered; refusing to replace it during certification.'
Assert-True (-not (Test-Path -LiteralPath $productRegistry)) `
'Waggle installer metadata already exists; refusing to overwrite another installation location.'
Assert-True (-not (Test-RegistryValue $runRegistry $runRegistryValue)) `
'A pre-existing Waggle autostart entry makes this certificate unsafe.'
$receipt.checks['runRegistryCollisionGuard'] = $true
Assert-NoForeignWaggleProcesses $appExecutable
$receipt.checks['foreignProcessCollisionGuard'] = $true
foreach ($shortcut in $shortcutCandidates) {
Assert-True (-not (Test-Path -LiteralPath $shortcut)) `
"A pre-existing Waggle shortcut makes this certificate unsafe: $shortcut"
}
Assert-True (-not (Test-Path -LiteralPath $profileDataDir)) `
"Profile data already exists at $profileDataDir; run this certificate under a disposable Windows user."
$profileAbsenceProven = $true
$processHome = [Environment]::GetEnvironmentVariable('HOME', 'Process')
if (-not [string]::IsNullOrWhiteSpace($processHome)) {
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath($processHome).TrimEnd('\'),
[System.IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd('\'),
[System.StringComparison]::OrdinalIgnoreCase
)) 'HOME must resolve to USERPROFILE so external profile isolation cannot be redirected.'
}
$externalProfileRootBaselines = @(
foreach ($target in $externalProfileRootTargets) {
Get-ExternalProfileRootSnapshot `
-Name ([string]$target.name) `
-Path ([string]$target.path)
}
)
$receipt.evidence['externalProfileRoots'] = @(
foreach ($baseline in $externalProfileRootBaselines) {
[ordered]@{
name = [string]$baseline.name
path = [string]$baseline.path
existedBefore = [bool]$baseline.existedBefore
entryCount = [long]$baseline.entryCount
manifestSha256 = $baseline.manifestSha256
}
}
)
$externalProfileRootsPreProven = $true
$receipt.checks['externalProfileRootsPreProven'] = $true
# The desktop webview and Rust shell currently share the fixed loopback port
# 3333 contract. Refuse before creating profile state rather than clean up
# after colliding with another installation.
Assert-TcpPortAvailable 3333
New-Item -ItemType Directory -Path $profileDataDir | Out-Null
$profileRoot = Get-Item -LiteralPath $profileDataDir -Force
Assert-True (($profileRoot.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) `
'Certificate profile root must not be a reparse point.'
Set-Content -LiteralPath $profileDataMarker -Value $runId -Encoding UTF8
$profileRootOwned = $true
$env:WAGGLE_PORT = '3333'
$env:WAGGLE_HOST = '127.0.0.1'
$ollamaPort = Get-FreeTcpPort
$env:OLLAMA_HOST = "http://127.0.0.1:$ollamaPort"
$env:VLLM_HOST = "http://127.0.0.1:$(Get-FreeTcpPort)"
$env:WAGGLE_SKIP_MARKETPLACE_SYNC = '1'
$isolationPath = Join-Path $scratchRoot 'isolated-path'
New-Item -ItemType Directory -Path $isolationPath -Force | Out-Null
$systemDirectory = [Environment]::GetFolderPath('System')
foreach ($toolName in @('tar.exe', 'taskkill.exe')) {
$sourceTool = Join-Path $systemDirectory $toolName
Assert-True (Test-Path -LiteralPath $sourceTool -PathType Leaf) `
"Required Windows inbox tool is missing: $sourceTool"
$toolSignature = Get-AuthenticodeSignature -LiteralPath $sourceTool
Assert-True ($toolSignature.Status -eq [System.Management.Automation.SignatureStatus]::Valid) `
"Required Windows inbox tool has no valid signature: $sourceTool"
$stagedTool = Join-Path $isolationPath $toolName
Copy-Item -LiteralPath $sourceTool -Destination $stagedTool
$sourceToolHash = (Get-FileHash -LiteralPath $sourceTool -Algorithm SHA256).Hash
Assert-True ((Get-FileHash -LiteralPath $stagedTool -Algorithm SHA256).Hash -eq $sourceToolHash) `
"Staged Windows inbox tool differs from its signed source: $toolName"
$receipt.evidence.windowsInboxTools[$toolName] = $sourceToolHash
}
$env:PATH = $isolationPath
foreach ($toolName in @('tar.exe', 'taskkill.exe')) {
$resolvedTool = Get-Command $toolName -CommandType Application -ErrorAction Stop |
Select-Object -First 1
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath($resolvedTool.Source),
[System.IO.Path]::GetFullPath((Join-Path $isolationPath $toolName)),
[System.StringComparison]::OrdinalIgnoreCase
)
) "Isolation PATH did not resolve the staged Windows inbox tool: $toolName"
}
$receipt.checks['windowsInboxTools'] = $true
foreach ($name in $environmentNamesToClear) {
[Environment]::SetEnvironmentVariable($name, $null, 'Process')
}
Assert-True (
[string]::IsNullOrEmpty(
[Environment]::GetEnvironmentVariable('WAGGLE_DATA_DIR', 'Process')
)
) 'WAGGLE_DATA_DIR must be absent so the packaged default-profile fallback is exercised.'
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath($dataDir).TrimEnd('\'),
[System.IO.Path]::GetFullPath((Join-Path $env:USERPROFILE '.waggle')).TrimEnd('\'),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Certificate data directory is not the real Windows default profile.'
$receipt.checks['defaultProfileDataDir'] = $true
$unexpectedApplications = @(
@('node.exe', 'npm.cmd', 'npx.cmd', 'docker.exe', 'ollama.exe', 'python.exe') |
Where-Object { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue }
)
Assert-True ($unexpectedApplications.Count -eq 0) `
"Isolation PATH still exposes external runtimes: $($unexpectedApplications -join ', ')"
$receipt.checks['externalRuntimeIsolation'] = $true
# NSIS /D must be the final argument and must not be quoted, even when its
# absolute path contains spaces. Do not add /R: the certificate launches and
# owns the exact installed process itself.
Assert-NoForeignWaggleProcesses $appExecutable
$installerStarted = $true
$upgradeVaultKeySha256 = $null
$upgradeDataMarkerSha256 = $null
$upgradeProfileMarkerSha256 = $null
$upgradeManagedModelName = $null
$upgradeManagedModelDigest = $null
if ($RequireVersionToVersionUpgrade) {
Assert-ArtifactIdentity `
$PreviousInstallerPath `
$ExpectedPreviousInstallerSha256 `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Previous installer before install'
Invoke-RawProcess $PreviousInstallerPath "/S /D=$installDir" 420
Wait-ForPathState $appExecutable $true
Assert-True (Test-Path -LiteralPath $uninstaller -PathType Leaf) `
'Previous installer did not create uninstall.exe.'
$previousRegistration = Get-ItemProperty -LiteralPath $uninstallRegistry
Assert-True ([string]$previousRegistration.DisplayVersion -eq $ExpectedPreviousVersion) `
'Previous installer registry version does not match the protected previous version.'
Assert-True ([string]::Equals(
([string]$previousRegistration.InstallLocation).Trim('"'),
$installDir,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Previous installer did not use the isolated install directory.'
$previousRegisteredUninstaller = ([string]$previousRegistration.UninstallString).Trim().Trim('"')
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath($previousRegisteredUninstaller),
[System.IO.Path]::GetFullPath($uninstaller),
[System.StringComparison]::OrdinalIgnoreCase
)) 'Previous installer registered an unexpected uninstaller.'
$previousProductRegistration = Get-Item -LiteralPath $productRegistry
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath(([string]$previousProductRegistration.GetValue('')).Trim('"')),
[System.IO.Path]::GetFullPath($installDir),
[System.StringComparison]::OrdinalIgnoreCase
)) 'Previous installer product metadata does not match the isolated target.'
$previousInstalledAppFile = Get-Item -LiteralPath $appExecutable
$previousInstalledAppSignature = Get-AuthenticodeSignature -LiteralPath $appExecutable
Assert-ExpectedAuthenticodeSignature `
$previousInstalledAppSignature `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Previous installed Waggle executable'
$previousInstalledProductVersion = Get-InstalledProductVersion `
$appExecutable 'Previous installed Waggle executable'
Assert-True ($previousInstalledProductVersion.normalized -eq $ExpectedPreviousVersion) `
'Previous installed executable version does not match the protected previous version.'
$receipt.previousInstalledApp = New-AuthenticodeArtifactReceipt `
$previousInstalledAppFile $previousInstalledAppSignature
$receipt.previousInstalledApp['productVersion'] = $previousInstalledProductVersion.raw
$receipt.upgrade.observedPreviousVersion = $previousInstalledProductVersion.normalized
$receipt.checks['previousInstall'] = $true
$receipt.checks['previousVersion'] = $true
$receipt.checks['previousInstalledAppAuthenticodeSignature'] = $true
$receipt.checks['previousInstalledAppAuthenticodeSigner'] = $true
$receipt.checks['previousInstalledAppAuthenticodeTimestamp'] = $true
$previousBaseUrl = "http://127.0.0.1:$($env:WAGGLE_PORT)"
Assert-TcpPortAvailable 3333
$webViewDebugPort = if ($WebViewDebugPort -gt 0) { $WebViewDebugPort } else { Get-FreeTcpPort }
$previousProcess = Start-InstalledApp $appExecutable $webViewDebugPort
try {
$null = Wait-ForHealth $previousBaseUrl $StartupTimeoutSeconds
$previousProcess.Refresh()
Assert-True (-not $previousProcess.HasExited) `
'The previous desktop process exited before upgrade state was prepared.'
$previousVaultKeyPath = Join-Path $dataDir '.vault-key'
Assert-VaultKeyAclRestricted $previousVaultKeyPath
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
Set-Content -LiteralPath $dataMarker -Value $runId -Encoding UTF8
Assert-True (Test-Path -LiteralPath $profileDataMarker -PathType Leaf) `
'Previous launch removed the profile preservation marker.'
$previousHeaders = Get-CertificateSessionHeaders `
$previousBaseUrl $nodeCommand.Source $bootstrapHelperPath $webViewDebugPort `
-AllowLegacyUi
$receipt.checks['previousUi'] = $true
$previousTier = Invoke-JsonRequest "$previousBaseUrl/api/tier" $previousHeaders
Assert-True ([string]$previousTier.tier -ceq 'FREE') `
"The protected previous release reported an unexpected effective tier: $($previousTier.tier)"
$receipt.checks['previousSoloTier'] = $true
if ($VerifyManagedModel) {
$previousBootstrapResponse = Invoke-JsonPostRequest `
"$previousBaseUrl/api/local-inference/bootstrap" `
@{} `
$previousHeaders `
$managedOperationTimeoutSeconds
$previousBootstrap = $previousBootstrapResponse.Content | ConvertFrom-Json
Assert-True (
[int]$previousBootstrapResponse.StatusCode -eq 200 -and
$previousBootstrap.ok -eq $true -and
$previousBootstrap.dockerRequired -eq $false
) 'The protected previous release could not bootstrap its managed local runtime.'
$previousPullResponse = Invoke-JsonPostRequest `
"$previousBaseUrl/api/local-inference/pull" `
@{ model = $managedCertificateModel } `
$previousHeaders `
$managedOperationTimeoutSeconds
$previousPull = $previousPullResponse.Content | ConvertFrom-Json
Assert-True (
[int]$previousPullResponse.StatusCode -eq 200 -and
$previousPull.ok -eq $true -and
$previousPull.verifiedGeneration -eq $true
) 'The protected previous release could not seed the managed local model.'
Assert-True ([string]$previousPull.digest -match '^sha256:[0-9a-f]{64}$') `
'The protected previous release returned no immutable managed-model digest.'
$upgradeManagedModelName = [string]$previousPull.model
$upgradeManagedModelDigest = [string]$previousPull.digest
Assert-True (-not [string]::IsNullOrWhiteSpace($upgradeManagedModelName)) `
'The protected previous release returned no managed-model identity.'
$receipt.upgrade.managedModelName = $upgradeManagedModelName
$receipt.upgrade.managedModelDigest = $upgradeManagedModelDigest
$receipt.checks['previousManagedModelSeeded'] = $true
}
$certificateLifecycleData = New-CertificateLifecycleData `
$previousBaseUrl $previousHeaders $runId $dataDir
$receipt.lifecycleData = $certificateLifecycleData
$receipt.checks['realWorkspaceAndMemorySeeded'] = $true
$upgradeVaultKeySha256 = (
Get-FileHash -LiteralPath $previousVaultKeyPath -Algorithm SHA256
).Hash
$upgradeDataMarkerSha256 = (
Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256
).Hash
$upgradeProfileMarkerSha256 = (
Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256
).Hash
$receipt.upgrade.vaultKeySha256 = $upgradeVaultKeySha256
$receipt.upgrade.configuredDataMarkerSha256 = $upgradeDataMarkerSha256
$receipt.upgrade.profileMarkerSha256 = $upgradeProfileMarkerSha256
$receipt.checks['previousLaunch'] = $true
} finally {
Stop-InstalledProcesses $appExecutable $serviceScript $managedRuntimeRoot
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
$previousProcess.Dispose()
}
Assert-NoForeignWaggleProcesses $appExecutable
Assert-ArtifactIdentity `
$InstallerPath `
$ExpectedCandidateInstallerSha256 `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Candidate installer before upgrade'
Invoke-RawProcess $InstallerPath "/S /D=$installDir" 420
Assert-ArtifactIdentity `
$InstallerPath `
$ExpectedCandidateInstallerSha256 `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Candidate installer after upgrade'
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
Wait-ForPathState $appExecutable $true
$upgradeRegistration = Get-ItemProperty -LiteralPath $uninstallRegistry
Assert-True ([string]$upgradeRegistration.DisplayVersion -eq $ExpectedCandidateVersion) `
'Candidate installer registry version does not match the protected candidate version.'
Assert-True ([string]::Equals(
([string]$upgradeRegistration.InstallLocation).Trim('"'),
$installDir,
[System.StringComparison]::OrdinalIgnoreCase
)) 'Upgrade changed the registered install directory.'
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath(([string]$upgradeRegistration.UninstallString).Trim().Trim('"')),
[System.IO.Path]::GetFullPath($previousRegisteredUninstaller),
[System.StringComparison]::OrdinalIgnoreCase
)) 'Upgrade changed the registered uninstaller location.'
$upgradeProductRegistration = Get-Item -LiteralPath $productRegistry
Assert-True ([string]::Equals(
[System.IO.Path]::GetFullPath(([string]$upgradeProductRegistration.GetValue('')).Trim('"')),
[System.IO.Path]::GetFullPath($installDir),
[System.StringComparison]::OrdinalIgnoreCase
)) 'Upgrade changed the product install location.'
Assert-True (
(Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256).Hash -eq $upgradeDataMarkerSha256
) 'Upgrade changed or removed configured user data.'
Assert-True (
(Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256).Hash -eq
$upgradeProfileMarkerSha256
) 'Upgrade changed or removed the profile data marker.'
$receipt.checks['candidateVersion'] = $true
$receipt.checks['versionToVersionUpgrade'] = $true
$receipt.checks['upgradeSameInstallDirectory'] = $true
$receipt.checks['upgradeConfiguredDataPreserved'] = $true
$receipt.checks['upgradeProfileDataPreserved'] = $true
$receipt.checks['upgradeRegistrations'] = $true
} else {
Invoke-RawProcess $InstallerPath "/S /D=$installDir" 420
}
Wait-ForPathState $appExecutable $true
Assert-True (Test-Path -LiteralPath $uninstaller -PathType Leaf) 'Installer did not create uninstall.exe'
Assert-True (Test-Path -LiteralPath $serviceScript -PathType Leaf) 'Installer omitted resources/service.js'
$installedSidecarProvenance = Get-SidecarProvenance $serviceScript
Assert-SidecarBundleBinding `
-Packaged $packagedSidecarProvenance `
-Installed $installedSidecarProvenance
$receipt.checks['sidecarSourceProvenance'] = $true
Assert-True (Test-Path -LiteralPath $canonicalMarketplaceDb -PathType Leaf) `
'The tracked canonical marketplace database is missing.'
Assert-True (Test-Path -LiteralPath $installedMarketplaceDb -PathType Leaf) `
'Installer omitted resources\marketplace.db'
$installedMarketplaceFile = Get-Item -LiteralPath $installedMarketplaceDb
Assert-True (
($installedMarketplaceFile.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0
) 'Installed resources\marketplace.db must not be a reparse point.'
$marketplaceResourceSha256 = (
Get-FileHash -LiteralPath $canonicalMarketplaceDb -Algorithm SHA256
).Hash
Assert-True (
(Get-FileHash -LiteralPath $installedMarketplaceDb -Algorithm SHA256).Hash -eq
$marketplaceResourceSha256
) 'Installed resources\marketplace.db does not match the tracked canonical database.'
$receipt.evidence['marketplaceResourceSha256'] = $marketplaceResourceSha256
$receipt.checks['marketplaceResource'] = $true
Assert-True (Test-Path -LiteralPath $bundledNode -PathType Leaf) `
'Installer omitted the bundled Node.js runtime'
Assert-True (Test-Path -LiteralPath (Join-Path $installDir 'resources\node_modules') -PathType Container) `
'Installer omitted staged runtime dependencies'
$bundledRuntimeFiles = @(
(Join-Path $bundledNpmRuntime 'package.json'),
(Join-Path $bundledNpmRuntime 'NODE-LICENSE'),
(Join-Path $bundledNpmRuntime 'node_modules\npm\LICENSE'),
$bundledNpmCli,
$bundledNpxCli,
$bundledNpmWrapper,
$bundledNpxWrapper
)
foreach ($runtimeFile in $bundledRuntimeFiles) {
Assert-True (Test-Path -LiteralPath $runtimeFile -PathType Leaf) `
"Installer omitted bundled npm runtime file: $runtimeFile"
}
$npmVersionOutput = @(& $bundledNode $bundledNpmCli --version 2>$null)
Assert-True ($LASTEXITCODE -eq 0 -and $npmVersionOutput.Count -eq 1) `
'Bundled npm CLI did not execute through the installed Node runtime.'
$npxVersionOutput = @(& $bundledNode $bundledNpxCli --version 2>$null)
Assert-True ($LASTEXITCODE -eq 0 -and $npxVersionOutput.Count -eq 1) `
'Bundled npx CLI did not execute through the installed Node runtime.'
$npmVersion = ([string]$npmVersionOutput[0]).Trim()
$npxVersion = ([string]$npxVersionOutput[0]).Trim()
Assert-True ($npmVersion -match '^\d+\.\d+\.\d+$' -and $npmVersion -eq $npxVersion) `
"Bundled npm/npx versions do not match: npm=$npmVersion npx=$npxVersion"
$cmdExe = Join-Path $env:SystemRoot 'System32\cmd.exe'
Invoke-RawProcess $cmdExe ('/d /s /c ""{0}" --version"' -f $bundledNpmWrapper) 60
Invoke-RawProcess $cmdExe ('/d /s /c ""{0}" --version"' -f $bundledNpxWrapper) 60
$offlinePackageSource = Join-Path $scratchRoot 'offline-npm-package'
$offlinePackageArchiveRoot = Join-Path $scratchRoot 'offline-npm-archive'
$offlinePackageArchivePayload = Join-Path $offlinePackageArchiveRoot 'package'
$offlinePackageArchive = Join-Path $scratchRoot 'waggle-offline-install-probe-1.0.0.tgz'
$offlinePackageTar = Join-Path $isolationPath 'tar.exe'
$offlineLifecycleWitnessPrefix = Join-Path $scratchRoot 'offline-npm-lifecycle'
$offlineInstallRoot = Join-Path $scratchRoot 'offline-npm-install'
$offlineCache = Join-Path $scratchRoot 'offline-npm-cache'
$isolatedUserConfig = Join-Path $scratchRoot 'empty-user.npmrc'
$isolatedGlobalConfig = Join-Path $scratchRoot 'empty-global.npmrc'
New-Item -ItemType Directory -Path @(
$offlinePackageSource,
$offlinePackageArchivePayload,
$offlineInstallRoot,
$offlineCache
) -Force | Out-Null
Set-Content -LiteralPath $isolatedUserConfig -Value '' -NoNewline
Set-Content -LiteralPath $isolatedGlobalConfig -Value '' -NoNewline
$offlinePackageManifest = [ordered]@{
name = 'waggle-offline-install-probe'
version = '1.0.0'
scripts = [ordered]@{
prepare = "node -e `"require('node:fs').writeFileSync(process.env.WAGGLE_NPM_LIFECYCLE_WITNESS_PREFIX + '-prepare-ran.txt','unexpected')`""
prepack = "node -e `"require('node:fs').writeFileSync(process.env.WAGGLE_NPM_LIFECYCLE_WITNESS_PREFIX + '-prepack-ran.txt','unexpected')`""
install = "node -e `"require('node:fs').writeFileSync(process.env.WAGGLE_NPM_LIFECYCLE_WITNESS_PREFIX + '-install-ran.txt','unexpected')`""
}
}
$offlinePackageManifest | ConvertTo-Json -Depth 4 |
Set-Content -LiteralPath (Join-Path $offlinePackageSource 'package.json') -Encoding UTF8
Copy-Item `
-LiteralPath (Join-Path $offlinePackageSource 'package.json') `
-Destination (Join-Path $offlinePackageArchivePayload 'package.json')
Invoke-RawProcess $offlinePackageTar (
'-czf "{0}" -C "{1}" package' -f $offlinePackageArchive, $offlinePackageArchiveRoot
) 60
$offlinePackageArchiveItem = Get-Item -LiteralPath $offlinePackageArchive -Force
Assert-True (
$offlinePackageArchiveItem -is [System.IO.FileInfo] -and
($offlinePackageArchiveItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0
) 'Offline npm probe archive is missing or is a reparse point.'
Assert-True (@(Get-ChildItem -LiteralPath $offlineCache -Force).Count -eq 0) `
'Offline npm certificate cache was not clean before the probe.'
[Environment]::SetEnvironmentVariable(
'WAGGLE_NPM_LIFECYCLE_WITNESS_PREFIX',
$offlineLifecycleWitnessPrefix,
'Process'
)
$npmInstallOutput = @(
& $bundledNode $bundledNpmCli install --offline --ignore-scripts --no-audit --no-fund `
--package-lock=false --save=false --userconfig $isolatedUserConfig `
--globalconfig $isolatedGlobalConfig --cache $offlineCache --prefix $offlineInstallRoot `
-- $offlinePackageArchive 2>&1
)
Assert-True ($LASTEXITCODE -eq 0) `
"Bundled npm offline local install failed: $($npmInstallOutput -join [Environment]::NewLine)"
$installedOfflinePackage = Join-Path $offlineInstallRoot 'node_modules\waggle-offline-install-probe'
Assert-True (Test-Path -LiteralPath (Join-Path $installedOfflinePackage 'package.json') -PathType Leaf) `
'Bundled npm did not install the local offline package.'
$installedOfflinePackageItem = Get-Item -LiteralPath $installedOfflinePackage -Force
Assert-True (
$installedOfflinePackageItem -is [System.IO.DirectoryInfo] -and
($installedOfflinePackageItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0
) 'Bundled npm installed the local offline package as a reparse point.'
foreach ($lifecycleMarker in @('prepare-ran.txt', 'prepack-ran.txt', 'install-ran.txt')) {
$lifecycleWitness = "$offlineLifecycleWitnessPrefix-$lifecycleMarker"
Assert-True (-not (Test-Path -LiteralPath $lifecycleWitness)) `
"Bundled npm executed lifecycle script marker $lifecycleMarker despite --ignore-scripts."
}
$receipt.checks['silentInstall'] = $true
$receipt.checks['bundledRuntimePayload'] = $true
$receipt.checks['bundledNpmCli'] = $true
$receipt.checks['bundledNpmWrappers'] = $true
$receipt.checks['bundledNpmOfflineInstall'] = $true
$receipt.checks['bundledNpmIgnoreScriptsFlagHonored'] = $true
$receipt.bundledNpm = [ordered]@{
version = $npmVersion
npmCli = $bundledNpmCli
npxCli = $bundledNpxCli
cacheWasClean = $true
offlinePackage = 'waggle-offline-install-probe@1.0.0'
ignoreScriptsFlagHonored = $true
}
$installedAppFile = Get-Item -LiteralPath $appExecutable
$installedAppSignature = Get-AuthenticodeSignature -LiteralPath $appExecutable
$receipt.installedApp = [ordered]@{
name = $installedAppFile.Name
sha256 = (Get-FileHash -LiteralPath $appExecutable -Algorithm SHA256).Hash
sizeBytes = $installedAppFile.Length
authenticodeStatus = [string]$installedAppSignature.Status
signatureType = [string]$installedAppSignature.SignatureType
signerSubject = $null
signerThumbprint = $null
timestampAuthoritySubject = $null
timestampAuthorityThumbprint = $null
timestampAuthorityNotBefore = $null
timestampAuthorityNotAfter = $null
}
if ($installedAppSignature.SignerCertificate) {
$receipt.installedApp.signerSubject = $installedAppSignature.SignerCertificate.Subject
$receipt.installedApp.signerThumbprint = $installedAppSignature.SignerCertificate.Thumbprint
}
if ($installedAppSignature.TimeStamperCertificate) {
$receipt.installedApp.timestampAuthoritySubject = $installedAppSignature.TimeStamperCertificate.Subject
$receipt.installedApp.timestampAuthorityThumbprint = $installedAppSignature.TimeStamperCertificate.Thumbprint
$receipt.installedApp.timestampAuthorityNotBefore = $installedAppSignature.TimeStamperCertificate.NotBefore.ToUniversalTime().ToString('o')
$receipt.installedApp.timestampAuthorityNotAfter = $installedAppSignature.TimeStamperCertificate.NotAfter.ToUniversalTime().ToString('o')
}
$receipt.checks['installedAppPayload'] = $true
if ($RequireAuthenticodeSignature) {
Assert-ExpectedAuthenticodeSignature `
$installedAppSignature $ExpectedSignerThumbprint $ExpectedSignerSubject 'Installed Waggle executable'
$receipt.checks['installedAppAuthenticodeSignature'] = $true
$receipt.checks['installedAppAuthenticodeSigner'] = $true
$receipt.checks['installedAppAuthenticodeTimestamp'] = $true
}
if ($RequireVersionToVersionUpgrade) {
$installedProductVersion = Get-InstalledProductVersion `
$appExecutable 'Installed Waggle executable'
Assert-True ($installedProductVersion.normalized -eq $ExpectedCandidateVersion) `
'Installed candidate executable version does not match the protected candidate version.'
$receipt.installedApp['productVersion'] = $installedProductVersion.raw
$receipt.upgrade.observedCandidateVersion = $installedProductVersion.normalized
Assert-LifecycleReceiptApprovedSigner `
$receipt $ExpectedSignerThumbprint $ExpectedSignerSubject
$receipt.checks['sameApprovedSigner'] = $true
}
$registered = Get-ItemProperty -LiteralPath $uninstallRegistry
Assert-True (
[string]::Equals(
([string]$registered.InstallLocation).Trim('"'),
$installDir,
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Installer registry location does not match the isolated target'
$registeredUninstallerCommand = ([string]$registered.UninstallString).Trim()
Assert-True (
$registeredUninstallerCommand.StartsWith('"') -and $registeredUninstallerCommand.EndsWith('"')
) 'Installer registered an ambiguous uninstall command'
$registeredUninstaller = $registeredUninstallerCommand.Trim('"')
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath($registeredUninstaller),
[System.IO.Path]::GetFullPath($uninstaller),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Installer registry uninstall command does not target the isolated uninstaller'
$receipt.checks['uninstallRegistration'] = $true
$receipt.checks['registeredUninstaller'] = $true
$productRegistration = Get-Item -LiteralPath $productRegistry
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath(([string]$productRegistration.GetValue('')).Trim('"')),
[System.IO.Path]::GetFullPath($installDir),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Installer product metadata does not match the isolated target'
$receipt.checks['productRegistration'] = $true
$desktopShortcut = $shortcutCandidates[0]
$startMenuShortcuts = @(
$shortcutCandidates | Select-Object -Skip 1 | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }
)
Assert-True (Test-Path -LiteralPath $desktopShortcut -PathType Leaf) `
'Installer did not create the desktop shortcut'
Assert-True ($startMenuShortcuts.Count -ge 1) 'Installer did not create a Start Menu shortcut'
$installedShortcuts = @($desktopShortcut) + $startMenuShortcuts
Assert-ShortcutTargets $installedShortcuts $appExecutable
$receipt.checks['shortcuts'] = $true
$baseUrl = "http://127.0.0.1:$($env:WAGGLE_PORT)"
Assert-TcpPortAvailable 3333
$webViewDebugPort = if ($WebViewDebugPort -gt 0) { $WebViewDebugPort } else { Get-FreeTcpPort }
$firstProcess = Start-InstalledApp $appExecutable $webViewDebugPort
try {
$health = Wait-ForHealth $baseUrl $StartupTimeoutSeconds
$firstProcess.Refresh()
Assert-True (-not $firstProcess.HasExited) 'The installed desktop process exited during first boot'
Assert-NoVisibleConsoleDescendant $firstProcess.Id
$serviceLogPath = Join-Path $dataDir 'logs\service.log'
Assert-True (Test-Path -LiteralPath $serviceLogPath -PathType Leaf) `
'Hidden sidecar did not create its diagnostic service log.'
$receipt.checks['firstBootHiddenService'] = $true
$receipt.checks['serviceLog'] = $true
$sidecarNpmPrefix = Join-Path $dataDir 'npm\prefix'
$sidecarNpmCache = Join-Path $dataDir 'npm\cache'
Assert-True (Test-Path -LiteralPath $sidecarNpmPrefix -PathType Container) `
'First boot did not create the sidecar npm prefix directory.'
Assert-True (Test-Path -LiteralPath $sidecarNpmCache -PathType Container) `
'First boot did not create the sidecar npm cache directory.'
$receipt.checks['sidecarNpmDataDirectories'] = $true
$vaultKeyPath = Join-Path $dataDir '.vault-key'
Assert-VaultKeyAclRestricted $vaultKeyPath
$receipt.checks['vaultKeyAclRestricted'] = $true
if ($RequireVersionToVersionUpgrade) {
Assert-True (
(Get-FileHash -LiteralPath $vaultKeyPath -Algorithm SHA256).Hash -eq $upgradeVaultKeySha256
) 'Upgrade changed the existing Windows vault key.'
Assert-True (
(Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256).Hash -eq $upgradeDataMarkerSha256
) 'Candidate launch changed or removed configured user data.'
Assert-True (
(Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256).Hash -eq
$upgradeProfileMarkerSha256
) 'Candidate launch changed or removed the profile data marker.'
$receipt.checks['upgradeVaultKeyPreserved'] = $true
$receipt.checks['candidateLaunch'] = $true
$receipt.checks['relaunchAfterUpgrade'] = $true
}
$proxy = Invoke-BuiltInProxyLivenessProbe -Uri "$baseUrl/v1/health/liveliness"
Assert-True ($proxy.status -eq 'healthy') 'Built-in provider proxy is not healthy'
Assert-True ((Get-HttpStatusCode "$baseUrl/api/tier") -eq 401) `
'A protected API route did not reject an unauthenticated loopback request'
$receipt.checks['unauthenticatedProtectedRoute'] = $true
$headers = Get-CertificateSessionHeaders `
$baseUrl $nodeCommand.Source $bootstrapHelperPath $webViewDebugPort
$receipt.checks['firstBootUi'] = $true
$tier = Invoke-JsonRequest "$baseUrl/api/tier" $headers
Assert-True ([string]$tier.tier -ceq 'FREE') `
"A clean Solo install reported an unexpected effective tier: $($tier.tier)"
$receipt.certifiedTier = 'FREE'
$receipt.checks['soloTier'] = $true
if ($RequireVersionToVersionUpgrade) {
Assert-True ($null -ne $certificateLifecycleData) `
'Upgrade lifecycle data was not created by the previous release.'
Assert-CertificateLifecycleData $baseUrl $headers $certificateLifecycleData $dataDir
$receipt.checks['upgradeRealWorkspaceAndMemoryPreserved'] = $true
} else {
$certificateLifecycleData = New-CertificateLifecycleData `
$baseUrl $headers $runId $dataDir
$receipt.lifecycleData = $certificateLifecycleData
$receipt.checks['realWorkspaceAndMemorySeeded'] = $true
}
$marketplace = Invoke-JsonRequest `
"$baseUrl/api/marketplace/search?type=mcp&source=mcp_registry&limit=100" `
$headers `
-TimeoutSeconds 30
$marketplacePackages = @($marketplace.packages)
Assert-True ($marketplacePackages.Count -ge 1) `
'Clean installed marketplace API returned no trusted MCP catalog entries.'
$marketplaceNames = @($marketplacePackages | ForEach-Object { [string]$_.name })
Assert-True ($marketplaceNames -contains 'memory') `
'Clean installed marketplace API did not return the canonical memory MCP.'
Assert-True (Test-Path -LiteralPath $writableMarketplaceDb -PathType Leaf) `
'First boot did not create the writable marketplace database.'
Assert-True (
(Get-FileHash -LiteralPath $installedMarketplaceDb -Algorithm SHA256).Hash -eq
$marketplaceResourceSha256
) 'First boot modified the immutable resources\marketplace.db payload.'
$receipt.checks['marketplaceApi'] = $true
if (-not $RequireVersionToVersionUpgrade) {
$chatProbeMessage = "installer-certificate-no-model-$runId"
$chatResponse = Invoke-JsonPostRequest "$baseUrl/api/chat" @{
message = $chatProbeMessage
sessionId = "installer-certificate-no-model-$runId"
} $headers
$chatContent = [string]$chatResponse.Content
Assert-True ([int]$chatResponse.StatusCode -eq 200) `
'Clean no-model chat did not return HTTP 200.'
Assert-True (
([string]$chatResponse.Headers['Content-Type']).StartsWith('text/event-stream')
) 'Clean no-model chat did not return an SSE stream.'
Assert-True ([regex]::Matches(
$chatContent,
'(?m)^event:[ \t]*done[ \t]*\r?$'
).Count -eq 1) 'Clean no-model chat did not complete with exactly one done event.'
Assert-True (-not ($chatContent -match '(?m)^event:[ \t]*error[ \t]*\r?$')) `
'Clean no-model chat emitted an error event.'
Assert-True ($chatContent.Contains('No AI model is ready')) `
'Clean no-model chat did not report that model setup is required.'
Assert-True (-not $chatContent.Contains($chatProbeMessage)) `
'Clean no-model chat echoed the prompt instead of reporting setup-required state.'
$receipt.checks['noModelChatSetupRequired'] = $true
}
$embedding = Invoke-JsonRequest "$baseUrl/api/embedding/status" $headers
Assert-True ($embedding.activeProvider -eq 'inprocess') `
"Clean install did not load the in-process embedding model: $($embedding.activeProvider)"
Assert-True ($embedding.modelName -eq 'Xenova/all-MiniLM-L6-v2') `
"Clean install loaded an unexpected embedding model: $($embedding.modelName)"
$embeddingModelPath = Join-Path $dataDir 'models\Xenova\all-MiniLM-L6-v2\onnx\model.onnx'
Assert-True (Test-Path -LiteralPath $embeddingModelPath -PathType Leaf) `
'In-process embedding model reported ready without a model payload'
$embeddingModelFile = Get-Item -LiteralPath $embeddingModelPath
Assert-True ($embeddingModelFile.Length -gt 10MB) 'Downloaded embedding model payload is unexpectedly small'
$localInference = Invoke-JsonRequest "$baseUrl/api/local-inference/status" $headers
Assert-True ($localInference.dockerRequired -eq $false) 'Local inference incorrectly requires Docker'
Assert-True ($localInference.managedRuntime.supported -eq $true) `
'Managed local inference runtime is not supported by the packaged Windows app'
if ($VerifyManagedModel) {
$bootstrapResponse = Invoke-JsonPostRequest `
"$baseUrl/api/local-inference/bootstrap" `
@{} `
$headers `
$managedOperationTimeoutSeconds
$bootstrap = $bootstrapResponse.Content | ConvertFrom-Json
Assert-True ([int]$bootstrapResponse.StatusCode -eq 200 -and $bootstrap.ok -eq $true) `
'The packaged managed local runtime did not bootstrap successfully.'
Assert-True ($bootstrap.dockerRequired -eq $false) `
'The packaged managed local runtime unexpectedly requires Docker.'
$receipt.checks['managedRuntimeBootstrap'] = $true
if ($RequireVersionToVersionUpgrade) {
Assert-True (
-not [string]::IsNullOrWhiteSpace($upgradeManagedModelName) -and
$upgradeManagedModelDigest -match '^sha256:[0-9a-f]{64}$'
) 'The upgrade certificate has no previous managed-model identity.'
$preservedStatus = Invoke-JsonRequest "$baseUrl/api/local-inference/status" $headers
$preservedOllamaServers = @(
$preservedStatus.servers | Where-Object { [string]$_.type -eq 'ollama' }
)
Assert-True ($preservedStatus.offlineReady -eq $true -and $preservedOllamaServers.Count -eq 1) `
'The candidate did not start the managed model preserved from the previous release.'
Assert-True (@($preservedOllamaServers[0].models) -contains $upgradeManagedModelName) `
'The candidate did not advertise the managed model preserved from the previous release.'
Assert-True ($null -ne $preservedOllamaServers[0].modelDigests) `
'The candidate did not advertise managed-model digests after upgrade.'
$preservedDigestProperty = $preservedOllamaServers[0].modelDigests.PSObject.Properties[
$upgradeManagedModelName
]
Assert-True (
$null -ne $preservedDigestProperty -and
[string]$preservedDigestProperty.Value -ceq $upgradeManagedModelDigest
) 'The candidate changed the managed-model digest during upgrade.'
$receipt.checks['upgradeManagedModelPreserved'] = $true
}
$pullResponse = Invoke-JsonPostRequest `
"$baseUrl/api/local-inference/pull" `
@{ model = $managedCertificateModel } `
$headers `
$managedOperationTimeoutSeconds
$pull = $pullResponse.Content | ConvertFrom-Json
Assert-True (
[int]$pullResponse.StatusCode -eq 200 -and
$pull.ok -eq $true -and
$pull.verifiedGeneration -eq $true
) 'The managed local model did not complete its generation probe.'
Assert-True (-not [string]::IsNullOrWhiteSpace([string]$pull.model)) `
'The managed local model pull returned no installed model identity.'
Assert-True ([string]$pull.digest -match '^sha256:[0-9a-f]{64}$') `
'The managed local model pull returned no immutable manifest digest.'
if ($RequireVersionToVersionUpgrade) {
Assert-True (
[string]$pull.model -ceq $upgradeManagedModelName -and
[string]$pull.digest -ceq $upgradeManagedModelDigest
) 'The candidate managed-model verification did not preserve the previous release digest.'
}
$receipt.checks['managedModelPull'] = $true
$managedStatus = Invoke-JsonRequest "$baseUrl/api/local-inference/status" $headers
Assert-True ($managedStatus.offlineReady -eq $true) `
'The managed local model was pulled but offline readiness is false.'
Assert-True ([int]$managedStatus.totalLocalModels -ge 1) `
'The managed runtime did not advertise an installed local model.'
$localChatResponse = Invoke-JsonPostRequest "$baseUrl/api/chat" @{
message = 'Reply with one short sentence confirming that local inference works.'
model = "ollama/$($pull.model)"
sessionId = "installer-certificate-managed-model-$runId"
} $headers 300
$localChatContent = [string]$localChatResponse.Content
Assert-True ([int]$localChatResponse.StatusCode -eq 200) `
'Managed local-model chat did not return HTTP 200.'
Assert-True (
([string]$localChatResponse.Headers['Content-Type']).StartsWith('text/event-stream')
) 'Managed local-model chat did not return an SSE stream.'
Assert-True (-not ($localChatContent -match '(?m)^event:[ \t]*error[ \t]*\r?$')) `
'Managed local-model chat emitted an error event.'
Assert-True (-not $localChatContent.Contains('No AI model is ready')) `
'Managed local-model chat fell back to setup-required mode.'
$localDoneMatches = [regex]::Matches(
$localChatContent,
'(?m)^event:[ \t]*done[ \t]*\r?\ndata:[ \t]*(?<data>[^\r\n]+)\r?$'
)
Assert-True ($localDoneMatches.Count -eq 1) `
'Managed local-model chat did not complete with exactly one done event.'
$localDone = $localDoneMatches[0].Groups['data'].Value | ConvertFrom-Json
Assert-True (-not [string]::IsNullOrWhiteSpace([string]$localDone.content)) `
'Managed local-model chat completed without response content.'
Assert-True ([string]$localDone.model -eq "ollama/$($pull.model)") `
'Managed local-model chat reported a model other than the requested local model.'
$receipt.managedModelVerified = $true
$receipt.managedModelDigest = [string]$pull.digest
$receipt.managedModel = [ordered]@{
name = [string]$pull.model
manifestDigest = [string]$pull.digest
pullGenerationVerified = $true
chatResponseChars = ([string]$localDone.content).Length
}
$receipt.checks['managedModelChat'] = $true
}
$receipt.checks['firstBoot'] = $true
$receipt.checks['database'] = $health.database.healthy
$receipt.checks['builtInProxyLiveness'] = $true
$receipt.checks['sessionAuth'] = $true
$receipt.embeddingPayloadReady = $true
$receipt.embeddingPayload = [ordered]@{
name = $embedding.modelName
sizeBytes = $embeddingModelFile.Length
}
$receipt.checks['embeddingPayloadReady'] = $true
$receipt.checks['dockerIndependentRuntimePrerequisites'] = $true
if ($RequireVersionToVersionUpgrade) {
Assert-True (
(Get-FileHash -LiteralPath (Join-Path $dataDir '.vault-key') -Algorithm SHA256).Hash -eq
$upgradeVaultKeySha256
) 'Candidate activity changed the upgraded Windows vault key.'
Assert-True (
(Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256).Hash -eq
$upgradeDataMarkerSha256
) 'Candidate activity changed or removed upgraded user data.'
Assert-True (
(Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256).Hash -eq
$upgradeProfileMarkerSha256
) 'Candidate activity changed or removed upgraded profile data.'
} else {
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
Set-Content -LiteralPath $dataMarker -Value $runId -Encoding UTF8
}
} finally {
Stop-InstalledProcesses $appExecutable $serviceScript $managedRuntimeRoot
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
$firstProcess.Dispose()
}
# Prove a same-version repair actually restores installed bytes instead of
# merely returning success while leaving a stale payload in place.
$serviceHash = (Get-FileHash -LiteralPath $serviceScript -Algorithm SHA256).Hash
Set-Content -LiteralPath $serviceScript -Value '// deliberately corrupted by installer certificate' -Encoding UTF8
Set-Content -LiteralPath $installedMarketplaceDb `
-Value 'deliberately corrupted by installer certificate' -Encoding UTF8
Assert-True ((Get-FileHash -LiteralPath $serviceScript -Algorithm SHA256).Hash -ne $serviceHash) `
'Could not prepare the repair probe'
Assert-True (
(Get-FileHash -LiteralPath $installedMarketplaceDb -Algorithm SHA256).Hash -ne
$marketplaceResourceSha256
) 'Could not prepare the marketplace repair probe'
Assert-NoForeignWaggleProcesses $appExecutable
if ($RequireVersionToVersionUpgrade) {
Assert-ArtifactIdentity `
$InstallerPath `
$ExpectedCandidateInstallerSha256 `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Candidate installer before repair'
}
Invoke-RawProcess $InstallerPath "/S /D=$installDir" 420
if ($RequireVersionToVersionUpgrade) {
Assert-ArtifactIdentity `
$InstallerPath `
$ExpectedCandidateInstallerSha256 `
$ExpectedSignerThumbprint `
$ExpectedSignerSubject `
'Candidate installer after repair'
}
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
Assert-True ((Get-FileHash -LiteralPath $serviceScript -Algorithm SHA256).Hash -eq $serviceHash) `
'Same-version repair did not restore resources/service.js'
Assert-True (
(Get-FileHash -LiteralPath $installedMarketplaceDb -Algorithm SHA256).Hash -eq
$marketplaceResourceSha256
) 'Same-version repair did not restore resources/marketplace.db'
Assert-True (Test-Path -LiteralPath $dataMarker -PathType Leaf) 'Repair removed user data'
if ($RequireVersionToVersionUpgrade) {
Assert-True (
(Get-FileHash -LiteralPath (Join-Path $dataDir '.vault-key') -Algorithm SHA256).Hash -eq
$upgradeVaultKeySha256
) 'Same-version repair changed the upgraded Windows vault key.'
Assert-True (
(Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256).Hash -eq $upgradeDataMarkerSha256
) 'Same-version repair changed upgraded user data.'
Assert-True (
(Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256).Hash -eq
$upgradeProfileMarkerSha256
) 'Same-version repair changed upgraded profile data.'
}
$repairRegistration = Get-ItemProperty -LiteralPath $uninstallRegistry
Assert-True (
[string]::Equals(
([string]$repairRegistration.InstallLocation).Trim('"'),
$installDir,
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Repair changed the registered install location'
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath(([string]$repairRegistration.UninstallString).Trim().Trim('"')),
[System.IO.Path]::GetFullPath($registeredUninstaller),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Repair changed the registered uninstaller'
$repairProductRegistration = Get-Item -LiteralPath $productRegistry
Assert-True (
[string]::Equals(
[System.IO.Path]::GetFullPath(([string]$repairProductRegistration.GetValue('')).Trim('"')),
[System.IO.Path]::GetFullPath($installDir),
[System.StringComparison]::OrdinalIgnoreCase
)
) 'Repair changed the product install location'
foreach ($shortcut in $installedShortcuts) {
Assert-True (Test-Path -LiteralPath $shortcut -PathType Leaf) `
"Repair removed an installed shortcut: $shortcut"
}
Assert-ShortcutTargets $installedShortcuts $appExecutable
$receipt.checks['sameVersionRepair'] = $true
$receipt.checks['repairRegistrations'] = $true
$runtimeConfirmedStopped = $false
$webViewDebugPort = if ($WebViewDebugPort -gt 0) { $WebViewDebugPort } else { Get-FreeTcpPort }
$secondProcess = Start-InstalledApp $appExecutable $webViewDebugPort
try {
$null = Wait-ForHealth $baseUrl $StartupTimeoutSeconds
$secondProcess.Refresh()
Assert-True (-not $secondProcess.HasExited) 'The installed desktop process exited after repair'
Assert-NoVisibleConsoleDescendant $secondProcess.Id
$receipt.checks['repairHiddenService'] = $true
$repairHeaders = Get-CertificateSessionHeaders `
$baseUrl $nodeCommand.Source $bootstrapHelperPath $webViewDebugPort
$receipt.checks['repairUi'] = $true
$repairTier = Invoke-JsonRequest "$baseUrl/api/tier" $repairHeaders
Assert-True ([string]$repairTier.tier -ceq 'FREE') `
"The repaired Solo install reported an unexpected effective tier: $($repairTier.tier)"
$receipt.checks['repairSoloTier'] = $true
Assert-CertificateLifecycleData $baseUrl $repairHeaders $certificateLifecycleData $dataDir
if ($VerifyManagedModel) {
$managedModelName = [string]$receipt.managedModel.name
Assert-True (-not [string]::IsNullOrWhiteSpace($managedModelName)) `
'The repair certificate lost the managed model identity.'
$repairManagedStatus = $null
$repairManagedDeadline = [DateTime]::UtcNow.AddSeconds(300)
do {
try {
$repairManagedStatus = Invoke-JsonRequest "$baseUrl/api/local-inference/status" $repairHeaders
} catch {
$repairManagedStatus = $null
}
if ($null -ne $repairManagedStatus -and $repairManagedStatus.offlineReady -eq $true) {
break
}
Start-Sleep -Seconds 1
} while ([DateTime]::UtcNow -lt $repairManagedDeadline)
Assert-True ($null -ne $repairManagedStatus -and $repairManagedStatus.offlineReady -eq $true) `
'The persisted managed local model did not become ready after repair.'
$repairOllamaServers = @(
$repairManagedStatus.servers | Where-Object { [string]$_.type -eq 'ollama' }
)
Assert-True ($repairOllamaServers.Count -eq 1) `
'The repaired install did not expose exactly one Ollama runtime.'
Assert-True (@($repairOllamaServers[0].models) -contains $managedModelName) `
'The repaired install did not preserve the certified managed model.'
Assert-True ($null -ne $repairOllamaServers[0].modelDigests) `
'The repaired install did not advertise managed-model digests.'
$repairDigestProperty = $repairOllamaServers[0].modelDigests.PSObject.Properties[
$managedModelName
]
Assert-True (
$null -ne $repairDigestProperty -and
[string]$repairDigestProperty.Value -ceq [string]$receipt.managedModelDigest
) 'The repaired install changed the certified managed-model digest.'
$receipt.checks['repairManagedModelDigestPreserved'] = $true
$proxyChatResponse = Invoke-JsonPostRequest "$baseUrl/v1/chat/completions" @{
model = "ollama/$managedModelName"
messages = @(
[ordered]@{
role = 'user'
content = 'Reply with one short sentence confirming that local proxy inference works.'
}
)
max_tokens = 32
stream = $false
} $repairHeaders 300
Assert-True ([int]$proxyChatResponse.StatusCode -eq 200) `
'The repaired built-in proxy did not complete a managed local-model request.'
$proxyChat = $proxyChatResponse.Content | ConvertFrom-Json
$proxyChoices = @($proxyChat.choices)
Assert-True ($proxyChoices.Count -eq 1) `
'The repaired built-in proxy returned an unexpected choice count.'
$proxyContent = [string]$proxyChoices[0].message.content
Assert-True (-not [string]::IsNullOrWhiteSpace($proxyContent)) `
'The repaired built-in proxy completed without response content.'
Assert-True ([string]$proxyChat.model -eq $managedModelName) `
'The repaired built-in proxy did not strip the Ollama routing prefix.'
$receipt.managedModel['proxyRestartChatResponseChars'] = $proxyContent.Length
$receipt.checks['managedModelProxyRestartChat'] = $true
}
$receipt.checks['repairPreservedData'] = $true
$receipt.checks['repairRealWorkspaceAndMemoryPreserved'] = $true
$receipt.checks['relaunchAfterRepair'] = $true
} finally {
Stop-InstalledProcesses $appExecutable $serviceScript $managedRuntimeRoot
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
$runtimeConfirmedStopped = $true
$secondProcess.Dispose()
}
Assert-NoForeignWaggleProcesses $appExecutable
$preUninstallDataManifest = @(Get-CertificateDataManifest $profileDataDir)
Assert-True ($preUninstallDataManifest.Count -gt 0) `
'Default-profile manifest is empty before uninstall.'
$receipt.lifecycleData['preUninstallManifestEntryCount'] = $preUninstallDataManifest.Count
$receipt.lifecycleData['preUninstallManifestSha256'] = Get-CertificateDataManifestDigest `
$preUninstallDataManifest
$runtimeConfirmedStopped = $false
Invoke-RawProcess $registeredUninstaller '/S' 300
Wait-ForPathState $installDir $false 90
# NSIS copies the uninstaller to a temporary process. The launcher can exit
# and the install directory can disappear before that process finishes the
# registry and shortcut tail, so wait on the actual postconditions.
Wait-ForPathState $uninstallRegistry $false 30
$receipt.checks['uninstallerCleanup'] = $true
Remove-CertificateProductRegistry $productRegistry $installDir
Assert-CertificateUninstallPostconditions `
-InstallDir $installDir `
-UninstallRegistry $uninstallRegistry `
-ProductRegistry $productRegistry `
-RunRegistry $runRegistry `
-RunRegistryValue $runRegistryValue `
-ShortcutPaths $shortcutCandidates `
-AppExecutable $appExecutable `
-ServiceScript $serviceScript `
-Port 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot `
-AdditionalPorts @($ollamaPort, $webViewDebugPort)
$uninstallPostconditionsConfirmed = $true
$runtimeConfirmedStopped = $true
$postUninstallDataManifest = @(Get-CertificateDataManifest $profileDataDir)
Assert-CertificateDataManifest $preUninstallDataManifest $postUninstallDataManifest
$postUninstallManifestSha256 = Get-CertificateDataManifestDigest $postUninstallDataManifest
Assert-True (
[string]$postUninstallManifestSha256 -ceq
[string]$receipt.lifecycleData.preUninstallManifestSha256
) 'Silent uninstall changed the default-profile manifest digest.'
$receipt.lifecycleData['postUninstallManifestSha256'] = $postUninstallManifestSha256
$receipt.checks['uninstallRealWorkspaceAndMemoryPreserved'] = $true
if ($VerifyManagedModel) {
$receipt.checks['managedRuntimeCleanup'] = $true
}
$installerStarted = $false
Assert-True (Test-Path -LiteralPath $dataMarker -PathType Leaf) `
'Silent uninstall did not preserve user data by default'
Assert-True (Test-Path -LiteralPath $profileDataMarker -PathType Leaf) `
'Silent uninstall removed the Windows profile data path'
Assert-True ((Get-Content -Raw -LiteralPath $profileDataMarker).Trim() -eq $runId) `
'Silent uninstall changed the Windows profile data marker'
if ($RequireVersionToVersionUpgrade) {
Assert-True (
(Get-FileHash -LiteralPath (Join-Path $dataDir '.vault-key') -Algorithm SHA256).Hash -eq
$upgradeVaultKeySha256
) 'Silent uninstall changed the upgraded Windows vault key.'
Assert-True (
(Get-FileHash -LiteralPath $dataMarker -Algorithm SHA256).Hash -eq $upgradeDataMarkerSha256
) 'Silent uninstall changed upgraded user data.'
Assert-True (
(Get-FileHash -LiteralPath $profileDataMarker -Algorithm SHA256).Hash -eq
$upgradeProfileMarkerSha256
) 'Silent uninstall changed upgraded profile data.'
}
$receipt.checks['silentUninstall'] = $true
$receipt.checks['certificateRegistryCleanup'] = $true
$receipt.checks['configuredDataDirPreserved'] = $true
$receipt.checks['profileDataPathPreserved'] = $true
Remove-CertificateProfileData `
$profileDataDir $profileDataMarker $runId $profileAbsenceProven $runtimeConfirmedStopped
$profileRootOwned = $false
$receipt.checks['certificateProfileCleanup'] = $true
$receipt.status = 'passed'
} catch {
$receipt.status = 'failed'
$receipt.error = $_.Exception.Message
$serviceLog = Join-Path $dataDir 'logs\service.log'
if (Test-Path -LiteralPath $serviceLog -PathType Leaf) {
$receipt['serviceLogTail'] = @(Get-Content -LiteralPath $serviceLog -Tail 80)
}
throw
} finally {
try {
$runtimeConfirmedStopped = $false
Stop-InstalledProcesses $appExecutable $serviceScript $managedRuntimeRoot
Wait-ForInstalledRuntimeStop $appExecutable $serviceScript 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot -AdditionalPorts @($ollamaPort, $webViewDebugPort)
Assert-NoForeignWaggleProcesses $appExecutable
Assert-TcpPortAvailable 3333
$runtimeConfirmedStopped = $true
} catch {
$receipt.status = 'failed'
$receipt['runtimeCleanupError'] = $_.Exception.Message
}
if (Test-Path -LiteralPath $uninstaller -PathType Leaf) {
try {
$runtimeConfirmedStopped = $false
Assert-NoForeignWaggleProcesses $appExecutable
Invoke-RawProcess $uninstaller '/S' 300
Wait-ForPathState $installDir $false 90
} catch {
$receipt.status = 'failed'
$receipt['uninstallerCleanupError'] = $_.Exception.Message
}
}
if ($installerStarted) {
try {
Remove-CertificateProductRegistry $productRegistry $installDir
} catch {
$receipt.status = 'failed'
$receipt['cleanupError'] = $_.Exception.Message
}
}
if ($installerStarted) {
$uninstallPostconditionsConfirmed = $false
try {
Assert-CertificateUninstallPostconditions `
-InstallDir $installDir `
-UninstallRegistry $uninstallRegistry `
-ProductRegistry $productRegistry `
-RunRegistry $runRegistry `
-RunRegistryValue $runRegistryValue `
-ShortcutPaths $shortcutCandidates `
-AppExecutable $appExecutable `
-ServiceScript $serviceScript `
-Port 3333 `
-ManagedRuntimeRoot $managedRuntimeRoot `
-AdditionalPorts @($ollamaPort, $webViewDebugPort)
$runtimeConfirmedStopped = $true
$uninstallPostconditionsConfirmed = $true
} catch {
$receipt.status = 'failed'
$receipt['uninstallPostconditionError'] = $_.Exception.Message
}
} elseif (-not $uninstallPostconditionsConfirmed) {
$uninstallPostconditionsConfirmed = $runtimeConfirmedStopped
}
if ($profileRootOwned -and $uninstallPostconditionsConfirmed) {
try {
Remove-CertificateProfileData `
$profileDataDir $profileDataMarker $runId $profileAbsenceProven $runtimeConfirmedStopped
$profileRootOwned = $false
} catch {
$receipt.status = 'failed'
$receipt['profileCleanupError'] = $_.Exception.Message
}
} elseif ($profileRootOwned) {
$receipt.status = 'failed'
$receipt['profileCleanupError'] = `
'Certificate profile was preserved because uninstall postconditions were not proven.'
}
if ($externalProfileRootsPreProven) {
try {
Assert-ExternalProfileRootsUnchanged $externalProfileRootBaselines
$receipt.checks['externalProfileRootsUnchanged'] = $true
} catch {
$receipt.status = 'failed'
$receipt['externalProfileIsolationError'] = $_.Exception.Message
}
} elseif ($receipt.status -eq 'passed') {
$receipt.status = 'failed'
$receipt['externalProfileIsolationError'] = `
'External profile roots were not proven before installer execution.'
}
if ($receipt.status -eq 'passed' -and -not $KeepArtifacts) {
try {
Remove-CertificateScratchRoot $scratchRoot $scratchOwnershipMarker $runId
} catch {
$receipt.status = 'failed'
$receipt['scratchCleanupError'] = $_.Exception.Message
}
}
try {
foreach ($name in $isolatedEnvironmentNames) {
[Environment]::SetEnvironmentVariable($name, $environmentSnapshot[$name], 'Process')
}
$receipt.checks['environmentRestored'] = $true
} catch {
$receipt.status = 'failed'
$receipt['environmentRestoreError'] = $_.Exception.Message
}
$receipt.finishedAt = [DateTime]::UtcNow.ToString('o')
$receipt['durationSeconds'] = [Math]::Round(([DateTime]::UtcNow - $startedAt).TotalSeconds, 3)
$receiptJson = $receipt | ConvertTo-Json -Depth 8
Write-CertificateReceipt $receiptReservation $receiptJson
}
if ($receipt.status -ne 'passed') {
$failureDetail = if ($receipt.error) {
$receipt.error
} elseif ($receipt.Contains('externalProfileIsolationError')) {
$receipt['externalProfileIsolationError']
} elseif ($receipt.Contains('environmentRestoreError')) {
$receipt['environmentRestoreError']
} elseif ($receipt.Contains('scratchCleanupError')) {
$receipt['scratchCleanupError']
} else {
'unknown failure'
}
throw "Windows installer certificate failed: $failureDetail"
}
Write-Host "Windows installer certificate passed: $ReceiptPath"