[CmdletBinding()] param( [Parameter(Mandatory)][string]$InstallerPath, [Parameter(Mandatory)][string]$ResultPath, [string]$ScriptRoot = $PSScriptRoot, [int]$TimeoutSeconds = 600, [string]$TaskNamePrefix = "CovenantSetupSelfInstall", [string]$TracePath = $(Join-Path (Split-Path -Parent $ResultPath) "trace") ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Quote-TaskArgument { param([Parameter(Mandatory)][string]$Value) return '"' + $Value.Replace('"', '""') + '"' } function Write-TraceEvent { param( [Parameter(Mandatory)][string]$Phase, [object]$Detail = $null ) try { New-Item -ItemType Directory -Force -Path $TracePath | Out-Null $event = [ordered]@{ time = (Get-Date).ToUniversalTime().ToString("o") pid = $PID script = Split-Path -Leaf $PSCommandPath phase = $Phase detail = $Detail } $event | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath (Join-Path $TracePath "guest-events.jsonl") -Encoding UTF8 } catch { Write-Warning "Failed to write trace event '$Phase': $($_.Exception.Message)" } } function Write-DiagnosticFile { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][scriptblock]$Capture ) $path = Join-Path $TracePath $Name Write-TraceEvent -Phase "diagnostic_file_start" -Detail @{ name = $Name } try { $value = & $Capture $value | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $path -Encoding UTF8 Write-TraceEvent -Phase "diagnostic_file_finish" -Detail @{ name = $Name } } catch { [ordered]@{ error = $_.Exception.Message type = $_.Exception.GetType().FullName } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $path -Encoding UTF8 Write-TraceEvent -Phase "diagnostic_file_error" -Detail @{ name = $Name error = $_.Exception.Message type = $_.Exception.GetType().FullName } } } function Convert-DateTimeForTrace { param([object]$Value) if ($null -eq $Value) { return $null } if ($Value -is [DateTime] -and $Value -eq [DateTime]::MinValue) { return $null } try { return ([DateTime]$Value).ToString("o") } catch { return [string]$Value } } function Export-SmokeDiagnostics { param([Parameter(Mandatory)][string]$Reason) Write-TraceEvent -Phase "diagnostics_start" -Detail @{ reason = $Reason } New-Item -ItemType Directory -Force -Path $TracePath | Out-Null Write-DiagnosticFile -Name "guest-context.json" -Capture { [ordered]@{ reason = $Reason computerName = $env:COMPUTERNAME userName = $env:USERNAME installerPath = $InstallerPath resultPath = $ResultPath tracePath = $TracePath taskName = $taskName installRoot = $installRoot } } Write-DiagnosticFile -Name "processes.json" -Capture { Get-CimInstance Win32_Process | Where-Object { $_.Name -match 'covenant|Covenant|powershell|pwsh|dotnet' } | Select-Object ProcessId, ParentProcessId, Name, CommandLine, CreationDate } Write-DiagnosticFile -Name "scheduled-task.json" -Capture { [ordered]@{ task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue } } Write-DiagnosticFile -Name "scheduled-task-events.json" -Capture { Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 300 -ErrorAction SilentlyContinue | Where-Object { $_.Message -like "*$taskName*" } | Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message } Write-DiagnosticFile -Name "application-events.json" -Capture { Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message } Write-DiagnosticFile -Name "system-events.json" -Capture { Get-WinEvent -FilterHashtable @{ LogName = "System"; StartTime = (Get-Date).AddHours(-2) } -MaxEvents 200 -ErrorAction SilentlyContinue | Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message } Write-DiagnosticFile -Name "install-root-files.json" -Capture { if (Test-Path -LiteralPath $installRoot) { Get-ChildItem -LiteralPath $installRoot -Force -Recurse | Select-Object FullName, Length, LastWriteTimeUtc, Attributes } else { [ordered]@{ exists = $false; path = $installRoot } } } Write-DiagnosticFile -Name "registry-state.json" -Capture { if (Test-Path -LiteralPath $registryPath) { Get-ItemProperty -LiteralPath $registryPath } else { [ordered]@{ exists = $false; path = $registryPath } } } Write-DiagnosticFile -Name "result-file.json" -Capture { if (Test-Path -LiteralPath $ResultPath) { Get-Content -LiteralPath $ResultPath -Raw } else { [ordered]@{ exists = $false; path = $ResultPath } } } Write-TraceEvent -Phase "diagnostics_finish" -Detail @{ reason = $Reason } } function Invoke-InteractiveOperation { param( [Parameter(Mandatory)][string]$TaskName, [Parameter(Mandatory)][string]$ExecutablePath, [Parameter(Mandatory)][string]$RunResultPath, [Parameter(Mandatory)][string]$OperationName, [string[]]$Arguments = @() ) $script:taskName = $TaskName Remove-Item -LiteralPath $RunResultPath -Force -ErrorAction SilentlyContinue # The task is started manually below. Keep the trigger far enough out that it # cannot fire a second copy while the smoke harness is collecting diagnostics. $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddDays(1) $actionArgumentItems = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", (Quote-TaskArgument -Value $interactiveScript), "-InstallerPath", (Quote-TaskArgument -Value $ExecutablePath), "-ResultPath", (Quote-TaskArgument -Value $RunResultPath), "-TimeoutSeconds", $TimeoutSeconds, "-TracePath", (Quote-TaskArgument -Value $TracePath), "-OperationName", (Quote-TaskArgument -Value $OperationName) ) if ($Arguments.Count -gt 0) { $argumentsJson = ConvertTo-Json -InputObject $Arguments -Compress $argumentsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($argumentsJson)) $actionArgumentItems += "-InstallerArgumentsBase64" $actionArgumentItems += $argumentsBase64 } $actionArguments = $actionArgumentItems -join " " $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArguments $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable try { Register-ScheduledTask ` -TaskName $TaskName ` -Action $action ` -Trigger $trigger ` -Settings $settings ` -Principal $principal ` -Force | Out-Null Write-TraceEvent -Phase "scheduled_task_registered" -Detail @{ taskName = $TaskName operationName = $OperationName executablePath = $ExecutablePath executableArgs = $Arguments actionArguments = $actionArguments runResultPath = $RunResultPath } Start-ScheduledTask -TaskName $TaskName Write-TraceEvent -Phase "scheduled_task_started" -Detail @{ taskName = $TaskName; operationName = $OperationName } $deadline = (Get-Date).AddSeconds($TimeoutSeconds + 120) $lastPoll = Get-Date "2000-01-01" while ((Get-Date) -lt $deadline) { if (Test-Path -LiteralPath $RunResultPath) { Write-TraceEvent -Phase "result_file_observed" -Detail @{ taskName = $TaskName operationName = $OperationName runResultPath = $RunResultPath } break } if (((Get-Date) - $lastPoll).TotalSeconds -ge 10) { $lastPoll = Get-Date $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Write-TraceEvent -Phase "waiting_for_interactive_task" -Detail @{ taskName = $TaskName operationName = $OperationName state = $(if ($task) { $task.State.ToString() } else { $null }) lastRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.LastRunTime } else { $null }) lastTaskResult = $(if ($taskInfo) { $taskInfo.LastTaskResult } else { $null }) nextRunTime = $(if ($taskInfo) { Convert-DateTimeForTrace -Value $taskInfo.NextRunTime } else { $null }) } } Start-Sleep -Seconds 2 } if (-not (Test-Path -LiteralPath $RunResultPath)) { Export-SmokeDiagnostics -Reason "${OperationName}_task_timeout" $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName throw "Timed out waiting for $OperationName interactive task. LastTaskResult=$($taskInfo.LastTaskResult)" } $runResult = Get-Content -LiteralPath $RunResultPath -Raw | ConvertFrom-Json Write-TraceEvent -Phase "interactive_task_result_read" -Detail @{ taskName = $TaskName operationName = $OperationName result = $runResult } if (-not $runResult.success) { Export-SmokeDiagnostics -Reason "${OperationName}_task_failed" throw "Interactive $OperationName task failed: $($runResult.error)" } return $runResult } finally { Write-TraceEvent -Phase "scheduled_task_unregister" -Detail @{ taskName = $TaskName; operationName = $OperationName } Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } } function Wait-ForUninstallCleanup { param([int]$TimeoutSeconds = 60) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $lastPoll = Get-Date "2000-01-01" while ((Get-Date) -lt $deadline) { $remaining = [ordered]@{ installRoot = Test-Path -LiteralPath $installRoot installedExe = Test-Path -LiteralPath $installedExe journalPath = Test-Path -LiteralPath $journalPath uninstallExe = Test-Path -LiteralPath $uninstallExe shortcutPath = Test-Path -LiteralPath $shortcutPath registryPath = Test-Path -LiteralPath $registryPath uninstallRegistryPath = Test-Path -LiteralPath $uninstallRegistryPath } if (-not ($remaining.installRoot -or $remaining.installedExe -or $remaining.journalPath -or $remaining.uninstallExe -or $remaining.shortcutPath -or $remaining.registryPath -or $remaining.uninstallRegistryPath)) { Write-TraceEvent -Phase "uninstall_cleanup_observed" -Detail $remaining return } if (((Get-Date) - $lastPoll).TotalSeconds -ge 5) { $lastPoll = Get-Date Write-TraceEvent -Phase "waiting_for_uninstall_cleanup" -Detail $remaining } Start-Sleep -Seconds 1 } throw "Uninstall cleanup did not complete within $TimeoutSeconds seconds." } $installRoot = Join-Path $env:LOCALAPPDATA "CovenantSetupSelfTest" $shortcutPath = Join-Path ([Environment]::GetFolderPath("Desktop")) "Covenant Setup Self Test.lnk" $registryPath = "HKCU:\Software\CovenantSetupSelfTest" $journalPath = Join-Path $installRoot "journal.json" $installedExe = Join-Path $installRoot "bin\covenant-setup.exe" $uninstallExe = Join-Path $installRoot "covenant-setup-uninstall.exe" $uninstallRegistryPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Covenant_Setup_Self_Test" $taskStamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() $installTaskName = "{0}-Install-{1}" -f $TaskNamePrefix, $taskStamp $uninstallTaskName = "{0}-Uninstall-{1}" -f $TaskNamePrefix, $taskStamp $taskName = $installTaskName $interactiveScript = Join-Path $ScriptRoot "Invoke-InteractiveInstaller.ps1" $installRunResultPath = $null $uninstallRunResultPath = $null try { Remove-Item -LiteralPath $TracePath -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $TracePath | Out-Null Write-TraceEvent -Phase "self_install_start" -Detail @{ installerPath = $InstallerPath resultPath = $ResultPath tracePath = $TracePath timeoutSeconds = $TimeoutSeconds } if (-not (Test-Path -LiteralPath $InstallerPath)) { throw "Installer not found in guest: $InstallerPath" } if (-not (Test-Path -LiteralPath $interactiveScript)) { throw "Interactive installer script not found in guest: $interactiveScript" } $resultDir = Split-Path -Parent $ResultPath if ($resultDir) { New-Item -ItemType Directory -Force -Path $resultDir | Out-Null } Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue $installRunResultPath = Join-Path $resultDir "install-run-result.json" $uninstallRunResultPath = Join-Path $resultDir "uninstall-run-result.json" Remove-Item -LiteralPath $installRunResultPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $uninstallRunResultPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $shortcutPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $uninstallRegistryPath -Recurse -Force -ErrorAction SilentlyContinue Write-TraceEvent -Phase "self_install_cleaned_previous_state" $installRunResult = Invoke-InteractiveOperation ` -TaskName $installTaskName ` -ExecutablePath $InstallerPath ` -RunResultPath $installRunResultPath ` -OperationName "install" ` -Arguments @("--headed", "--automation") Write-TraceEvent -Phase "verification_start" if (-not (Test-Path -LiteralPath $installedExe)) { throw "Installed executable missing: $installedExe" } if (-not (Test-Path -LiteralPath $journalPath)) { throw "Journal missing: $journalPath" } if (-not (Test-Path -LiteralPath $uninstallExe)) { throw "Installed uninstaller missing: $uninstallExe" } if (-not (Test-Path -LiteralPath $shortcutPath)) { throw "Desktop shortcut missing: $shortcutPath" } if (-not (Test-Path -LiteralPath $registryPath)) { throw "Registry key missing: $registryPath" } $installRootValue = Get-ItemPropertyValue -Path $registryPath -Name "InstallRoot" if ($installRootValue -ne $installRoot) { throw "Unexpected registry InstallRoot value: $installRootValue" } $journal = Get-Content -LiteralPath $journalPath -Raw | ConvertFrom-Json $journalActionTypes = @($journal.actions | ForEach-Object { $_.type }) if ($journal.app_name -ne "Covenant Setup Self Test") { throw "Unexpected journal app name: $($journal.app_name)" } if ($journalActionTypes -notcontains "copy_file") { throw "Journal did not record the packaged payload copy." } $uninstallRunResult = Invoke-InteractiveOperation ` -TaskName $uninstallTaskName ` -ExecutablePath $uninstallExe ` -RunResultPath $uninstallRunResultPath ` -OperationName "uninstall" ` -Arguments @("--headed", "--automation", "uninstall", $journalPath) Write-TraceEvent -Phase "uninstall_verification_start" Wait-ForUninstallCleanup -TimeoutSeconds 60 if (Test-Path -LiteralPath $installedExe) { throw "Installed executable still exists after uninstall: $installedExe" } if (Test-Path -LiteralPath $journalPath) { throw "Journal still exists after uninstall: $journalPath" } if (Test-Path -LiteralPath $uninstallExe) { throw "Installed uninstaller still exists after uninstall: $uninstallExe" } if (Test-Path -LiteralPath $shortcutPath) { throw "Desktop shortcut still exists after uninstall: $shortcutPath" } if (Test-Path -LiteralPath $registryPath) { throw "Registry key still exists after uninstall: $registryPath" } if (Test-Path -LiteralPath $uninstallRegistryPath) { throw "Installed Apps registry key still exists after uninstall: $uninstallRegistryPath" } if (Test-Path -LiteralPath $installRoot) { throw "Install root still exists after uninstall: $installRoot" } $verification = [ordered]@{ success = $true exitCode = 0 installExitCode = [int]$installRunResult.exitCode uninstallExitCode = [int]$uninstallRunResult.exitCode installerPath = $InstallerPath installRoot = $installRoot installedExe = $installedExe journalPath = $journalPath uninstallExe = $uninstallExe shortcutPath = $shortcutPath registryPath = $registryPath uninstallRegistryPath = $uninstallRegistryPath tracePath = $TracePath installRunResultPath = $installRunResultPath uninstallRunResultPath = $uninstallRunResultPath journalActionTypes = $journalActionTypes installTaskName = $installTaskName uninstallTaskName = $uninstallTaskName installStartedAt = $installRunResult.startedAt installFinishedAt = $installRunResult.finishedAt uninstallStartedAt = $uninstallRunResult.startedAt uninstallFinishedAt = $uninstallRunResult.finishedAt uninstallVerified = $true } $verification | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 Write-TraceEvent -Phase "self_install_success" -Detail $verification } catch { Write-TraceEvent -Phase "self_install_error" -Detail @{ error = $_.Exception.Message type = $_.Exception.GetType().FullName } Export-SmokeDiagnostics -Reason "self_install_error" $failure = [ordered]@{ success = $false error = $_.Exception.Message taskName = $taskName installTaskName = $installTaskName uninstallTaskName = $uninstallTaskName installRoot = $installRoot resultPath = $ResultPath installRunResultPath = $installRunResultPath uninstallRunResultPath = $uninstallRunResultPath tracePath = $TracePath } $failure | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding UTF8 exit 1 } finally { foreach ($taskToRemove in @($installTaskName, $uninstallTaskName)) { if ($taskToRemove) { Unregister-ScheduledTask -TaskName $taskToRemove -Confirm:$false -ErrorAction SilentlyContinue } } }