Files
AutopilotMonitor/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1
T
Oliver Kieselbach 3a898b5071 Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- Loader 1.3 downloads into a protected run folder of its own
  (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
  is signed by the pinned publisher and declares the installer role and version
  2.7 or later, removes the folder after a successful run and never touches the
  agent folder. Unbound arguments now reach the installer by name; the array
  splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
  read HKLM through the 64-bit view (a 32-bit host never saw the deployment
  marker), guard 5 is covered by the agent check. An agent's folder stays as it
  is, a skip without agent removes the folder, an install starts from an emptied,
  protected folder and stages the ZIP in a run folder. It moves the MSI's working
  directory out, writes stderr only on failure and removes the copy older loaders
  left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
  Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
  (#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
  publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
  Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
  (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
  the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
  a real standard user.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:34:54 +02:00

267 lines
11 KiB
PowerShell

# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 (v2.7) incl. the relax exemption for
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
# or a positively identified Windows 365 Cloud PC, the guard 4 (uptime)
# exemption while the Cloud PC relax is active, and the agent check.
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
function Write-Step {
param(
[string]$Status,
[string]$Message
)
$prefix = switch ($Status) {
'PASS' { '[PASS]' }
'SKIP' { '[SKIP]' }
'WARN' { '[WARN]' }
'INFO' { '[INFO]' }
default { '[....]' }
}
Write-Host "$prefix $Message"
}
# HKLM through the 64-bit view, as the installer reads it: a 32-bit host would read WOW6432Node.
function Get-RegistryValueSafe {
param(
[string]$SubKey,
[string]$Name
)
try {
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Close() }
} finally {
$hive.Close()
}
}
catch {
return $null
}
}
function Test-RegistryKeySafe {
param([string]$SubKey)
try {
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $false }
$key.Close()
return $true
} finally {
$hive.Close()
}
}
catch {
return $false
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
}
catch {
return $false
}
}
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
$cloudPcRegMarker = Test-RegistryKeySafe -SubKey 'SOFTWARE\Microsoft\Windows365'
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
$wouldInstall = $true
$reasons = New-Object System.Collections.Generic.List[string]
Write-Host ''
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run) ==='
Write-Host ''
# Guard 1: Agent was already deployed on this device (survives self-destruct)
$deployed = Get-RegistryValueSafe -SubKey 'SOFTWARE\AutopilotMonitor' -Name 'Deployed'
if ($deployed) {
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
$wouldInstall = $false
$reasons.Add("Previously deployed marker exists: $deployed")
} else {
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
}
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$wmiProfileQueryFailed = $false
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
}
catch {
$wmiProfileQueryFailed = $true
}
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) |
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
if ($wmiProfileQueryFailed) {
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
}
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# Relax: the ONLY exemption to guards 2+3. Two triggers -- OOBE InProgress (Windows
# Backup for Organizations restore) or Cloud PC identity (headless provisioning,
# profile at first connect). Either additionally requires exactly one fresh profile.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
if ($profilePaths.Count -eq 1) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if (($oobeInProgress -or $isCloudPc) -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "Cloud PC markers: Windows365 registry key = $cloudPcRegMarker ; CloudManagedDesktopExtension service = $cloudPcSvcMarker ; IsCloudPc = $isCloudPc"
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
$wouldInstall = $false
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
$trigger = if ($oobeInProgress) { 'OOBE InProgress' } else { 'Cloud PC' }
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but relax active ($trigger + single fresh profile) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = Get-RegistryValueSafe -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser'
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
$lastBoot = $null
$uptimeHours = $null
try {
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
if ($oobeRelax -and $isCloudPc) {
# Cloud PCs run headless for days before the user's first connect; the
# fresh-profile window is the time anchor there, not boot uptime.
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h (over ${MaxBootstrapWindowHours}h), but the Cloud PC relax is active -- not blocking."
} else {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
}
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
}
catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Agent check: an installed agent (its executable or its task; the marker is guard 1) is
# left as it is.
$agentTask = Get-ScheduledTask -TaskName 'AutopilotMonitor-Agent' -ErrorAction SilentlyContinue
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Agent check: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
$reasons.Add("Agent binary already present: $AgentExePath")
} elseif ($agentTask) {
Write-Step -Status 'SKIP' -Message "Agent check: Agent task 'AutopilotMonitor-Agent' exists."
$wouldInstall = $false
$reasons.Add("Agent task present: AutopilotMonitor-Agent")
} else {
Write-Step -Status 'PASS' -Message "Agent check: No agent binary at '$AgentExePath' and no agent task."
}
Write-Host ''
Write-Host '=== Result ==='
Write-Host ''
if ($wouldInstall) {
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
} else {
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
}
Write-Host ''
Write-Host '=== Summary ==='
if ($reasons.Count -eq 0) {
Write-Host 'No blocking reasons found.'
} else {
foreach ($reason in $reasons) {
Write-Host " - $reason"
}
}
Write-Host ''
Write-Host '=== Raw Values ==='
Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "CloudPcRegistryMarker : $cloudPcRegMarker"
Write-Host "CloudPcServiceMarker : $cloudPcSvcMarker"
Write-Host "IsCloudPc : $isCloudPc"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "RelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
Write-Host "UptimeHours : $([string]$uptimeHours)"
Write-Host "AgentExePath : $AgentExePath"
Write-Host ''