From 3392a23da6a1979a952c0181fa19e51bb9eae0c2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 31 Jul 2026 01:37:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(bootstrap):=20promote=20dev=20script=20v2.?= =?UTF-8?q?2=20to=20stable=20=E2=80=94=20download.autopilotmonitor.com=20e?= =?UTF-8?q?ndpoint=20+=20OOBE-relax=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stable Install-AutopilotMonitor.ps1 is now the previously field-tested -dev variant (v2.2): agent download via the Front Door alias https://download.autopilotmonitor.com/agent/ instead of the legacy blob host, mandatory SHA-256 manifest verification (Content-MD5 fallback removed), and the OOBE-relax exemption for the Windows Backup for Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the same guard logic; the separate -dev.ps1 files are gone. CI dev-render anchor literals in build-agent.yml updated to the alias URLs (stable + dev channel) so the literal-substitution check keeps passing; local build.ps1 anchors updated too (gitignored). Co-authored-by: Claude Fable 5 --- .github/workflows/build-agent.yml | 4 +- .../Install-AutopilotMonitor-dev.ps1 | 369 ------------------ .../Bootstrap/Install-AutopilotMonitor.ps1 | 219 +++++++---- .../Test-ShouldBootstrapAgent-dev.ps1 | 213 ---------- .../Bootstrap/Test-ShouldBootstrapAgent.ps1 | 109 ++++-- 5 files changed, 213 insertions(+), 701 deletions(-) delete mode 100644 scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 delete mode 100644 scripts/Bootstrap/Test-ShouldBootstrapAgent-dev.ps1 diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index d08a50eb..75663668 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -194,8 +194,8 @@ jobs: # Dev variant = literal-string substitution of the two URL/manifest defaults. # Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap). if ('${{ inputs.publish_as_stable }}' -eq 'true') { - $stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"' - $devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"' + $stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"' + $devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"' $stableMan = '$VersionJsonName = "version.json"' $devMan = '$VersionJsonName = "version-dev.json"' diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 deleted file mode 100644 index 624bf626..00000000 --- a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 +++ /dev/null @@ -1,369 +0,0 @@ -<# -.SYNOPSIS - Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV) - -.DESCRIPTION - Designed to be deployed via Intune as a PowerShell Script during Autopilot. - Runs very early in the enrollment process (first Intune action) and: - 1. Runs pre-flight guards to skip productive devices and ghost re-installs. - An OOBE-relax exemption allows install when a single freshly-created profile - appears DURING OOBE (the "Windows Backup for Organizations" restore case). - 2. Downloads the monitoring agent ZIP from Azure Blob Storage - 3. Verifies integrity via SHA-256 hash from the version manifest (mandatory) - 4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent - 5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime) - 6. Verifies the runtime process actually launched - Agent self-destructs when enrollment completes. - - OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser) - normally means "productive -> skip". Windows Backup for Organizations breaks that - by restoring settings and creating ONE profile while still in OOBE. We relax guards - 2+3 ONLY when all three positives hold, and nothing else: - (a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND - (b) there is exactly ONE real user profile, AND - (c) that profile was created within $OobeProfileMaxAgeMinutes minutes. - Any miss keeps the original skip (SKIP-safe: we never install on uncertainty). - -.PARAMETER AgentDownloadUrl - URL to download the agent ZIP from. Defaults to the production blob; override only - for parallel lab/dev assignments that point at a separate pre-release ZIP. - -.PARAMETER VersionJsonName - Filename of the integrity manifest in the same blob container as the agent ZIP. - Defaults to "version.json"; override only for parallel lab/dev manifests. - -.PARAMETER MaxBootstrapWindowHours - Maximum device uptime (hours) within which the bootstrap is still considered - valid. Devices booted more than this many hours ago are skipped because we no - longer trust their OOBE state. Default: 12. - -.PARAMETER OobeProfileMaxAgeMinutes - Maximum age (minutes) of the single profile that may appear during OOBE for the - OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an - older profile is treated as a productive device. Default: 15. - -.NOTES - - Agent is temporary and auto-removes after enrollment. - - All files live under C:\ProgramData\AutopilotMonitor (easy cleanup). - - One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed - (prevents ghost re-installs on re-Autopilot of the same device). - - Scheduled Task survives reboots during enrollment. - - OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. - Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack - the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs: - https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo - - This script MUST remain pure ASCII (no Unicode/UTF-8 special chars). - PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. - -.CHANGELOG - 2026-07-20 v2.2 Switched default download endpoint from the legacy blob - (autopilotmonitor.blob.core.windows.net) to the Front Door alias - https://download.autopilotmonitor.com/agent/ (route /agent/*). - version.json is still derived from the same container. - 2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT - SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a - single freshly-created profile appears in OOBE (Windows Backup for - Organizations). SKIP-safe when the API is absent (< 1809). - Removed legacy Content-MD5 fallback; SHA-256 manifest verification is - now mandatory (no hash -> bootstrap aborts, no unverified install). - 2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s - TenantId-wait), so the script only calls `--install` plain. - Hardened post-install with a 10 s runtime-process verify. - 2026-04-09 v1.1 Introduced explicit script version, logged on startup. - 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard - (registry deployment marker, WMI/filesystem user profile, last - logged-on user, 12 h bootstrap window). - 2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1. - 2026-03-29 Hardened integrity check: SHA-256 verification via version.json. - 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent. - 2026-02-12 Robust download with integrity check (Content-MD5), boot time support, - pre-flight check to skip if agent already installed. - 2026-02-05 Initial version. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $false)] - [string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip", - - [Parameter(Mandatory = $false)] - [string]$VersionJsonName = "version.json", - - [Parameter(Mandatory = $false)] - [int]$MaxBootstrapWindowHours = 12, - - [Parameter(Mandatory = $false)] - [int]$OobeProfileMaxAgeMinutes = 15 -) - -# Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "2.2" - -# Configuration - Everything in ProgramData for easy cleanup -$AgentBasePath = "$env:ProgramData\AutopilotMonitor" -$AgentBinPath = "$AgentBasePath\Agent" -$AgentLogPath = "$AgentBasePath\Logs" -$LogFile = "$AgentLogPath\bootstrap_agent.log" - -New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null -New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null - -function Write-Log { - param([string]$Message) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logMessage = "[$timestamp] $Message" - Write-Output $logMessage - Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue -} - -# Positive OOBE check via the documented WinRT API -# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. -# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted), -# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false -# -> SKIP-safe (we never relax a guard on uncertainty). -# 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 - } -} - -try { - Write-Log "===== Autopilot Monitor Bootstrap Started =====" - Write-Log "Bootstrap script version: v$ScriptVersion" - - # Guard 1: Agent was already deployed on this device (registry marker survives self-destruct) - $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed - if ($deployed) { - Write-Log "SKIP: Agent was previously deployed at $deployed." - exit 0 - } - - # Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem). - $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$' - - # 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 { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } - - (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName - ) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } | - Select-Object -Unique - ) - - # OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for - # Organizations restore (one user signs in + restores DURING OOBE). All three must hold: - # OOBE InProgress AND exactly one profile AND that profile created < N minutes ago. - $oobeRelax = $false - $oobeInProgress = Test-OobeInProgress - if ($profilePaths.Count -eq 1 -and $oobeInProgress) { - try { - $created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc - $ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes - if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) { - $oobeRelax = $true - Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes) - } else { - Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes) - } - } catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" } - } elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) { - Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)." - } elseif ($profilePaths.Count -gt 1) { - Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count) - } - - # Guard 2: No real user profile should exist yet (primary productive-device guard), - # unless the OOBE-relax signature holds. - if ($profilePaths.Count -gt 0 -and -not $oobeRelax) { - $details = (($profilePaths | ForEach-Object { - $leaf = Split-Path $_ -Leaf - try { - $c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc - "{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes - } catch { "$leaf (age unknown)" } - }) | Select-Object -First 3) -join '; ' - Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive." - exit 0 - } - - # Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet - # (same OOBE-relax exemption: the restoring user signs in during OOBE). - $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser - if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) { - Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." - exit 0 - } - - # Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window. - # Sleep/standby does not reset uptime, only real boot/restart does. - $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime - $uptimeHours = ((Get-Date) - $lastBoot).TotalHours - Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)" - if ($uptimeHours -gt $MaxBootstrapWindowHours) { - Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h." - exit 0 - } - - # Guard 5: Is the agent already installed? (leftover from previous run) - if (Test-Path $AgentBinPath) { - $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue - if ($existingAgent) { - Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)." - exit 0 - } - } - - Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)" - - # Download and extract agent binaries - $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" - - if (Test-Path $agentExePath) { - Write-Log "Agent already installed at $agentExePath" - } - else { - Write-Log "Downloading agent from $AgentDownloadUrl..." - - try { - # Derive manifest URL from the agent download URL (same blob container) - $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName - $expectedSha256 = $null - $manifestAttempts = 3 - $manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment - - for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) { - try { - Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..." - $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop - if ($versionJsonResponse.sha256) { - $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() - Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))" - } else { - Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified." - } - break # manifest reached (with or without hash); retrying would not help - } - catch { - Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)" - if ($attempt -lt $manifestAttempts) { - Start-Sleep -Seconds $manifestDelays[$attempt - 1] - } - } - } - - $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" - $maxDownloadAttempts = 3 - $downloadAttempt = 0 - - do { - $downloadAttempt++ - try { - Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" - $downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew() - Invoke-WebRequest ` - -Uri $AgentDownloadUrl ` - -OutFile $zipPath ` - -UseBasicParsing ` - -TimeoutSec 30 ` - -ErrorAction Stop - $downloadStopwatch.Stop() - $downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1) - Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)" - break - } - catch { - if ($downloadAttempt -ge $maxDownloadAttempts) { - throw - } - - $retryDelaysInSeconds = @(2, 4, 8) - $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] - Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." - Start-Sleep -Seconds $retryDelaySeconds - } - } while ($downloadAttempt -lt $maxDownloadAttempts) - - # SHA-256 from the version manifest is the only accepted integrity proof. - # No hash -> refuse to install an unverified ZIP (SKIP-safe security posture). - if (-not $expectedSha256) { - throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP." - } - $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - Write-Log "Validating SHA-256 hash against manifest" - if ($actualSha256 -ne $expectedSha256) { - throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." - } - Write-Log "SHA-256 integrity check passed" - - Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force - Write-Log "Extracted agent to $AgentBinPath" - - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Write-Log "Cleaned up temporary files" - - if (-not (Test-Path $agentExePath)) { - throw "Agent executable not found after extraction at $agentExePath" - } - - Write-Log "Agent installation completed successfully" - } - catch { - Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" - throw - } - } - - Write-Log "Calling agent install mode (--install)..." - & $agentExePath --install - $installExitCode = $LASTEXITCODE - if ($installExitCode -ne 0) { - throw "Agent install failed with exit code $installExitCode" - } - Write-Log "Agent install mode completed successfully" - - $runtimeProcessName = 'AutopilotMonitor.Agent' - $verifyTimeoutSec = 10 - $verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec) - $runtimeProc = $null - while ((Get-Date) -lt $verifyDeadline) { - $runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -ne $runtimeProc) { break } - Start-Sleep -Milliseconds 500 - } - if ($null -ne $runtimeProc) { - $startedUtc = 'unavailable' - try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { } - Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc) - } else { - Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec) - } - - Write-Log "===== Bootstrap Completed Successfully =====" - - exit 0 -} -catch { - Write-Log "===== Bootstrap FAILED =====" - Write-Log "ERROR: $($_.Exception.Message)" - Write-Log "Stack trace: $($_.ScriptStackTrace)" - Write-Log "Please check log file: $LogFile" - - $errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" - if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' } - [Console]::Error.WriteLine($errMsg) - - exit 1 -} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index b0c21833..d9992ea7 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -5,18 +5,28 @@ .DESCRIPTION Designed to be deployed via Intune as a PowerShell Script during Autopilot. Runs very early in the enrollment process (first Intune action) and: - 1. Runs five pre-flight guards to skip productive devices and ghost re-installs + 1. Runs pre-flight guards to skip productive devices and ghost re-installs. + An OOBE-relax exemption allows install when a single freshly-created profile + appears DURING OOBE (the "Windows Backup for Organizations" restore case). 2. Downloads the monitoring agent ZIP from Azure Blob Storage - 3. Verifies integrity via SHA-256 hash from the version manifest - (legacy fallback: Content-MD5 header from the blob response) + 3. Verifies integrity via SHA-256 hash from the version manifest (mandatory) 4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent 5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime) 6. Verifies the runtime process actually launched Agent self-destructs when enrollment completes. + OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser) + normally means "productive -> skip". Windows Backup for Organizations breaks that + by restoring settings and creating ONE profile while still in OOBE. We relax guards + 2+3 ONLY when all three positives hold, and nothing else: + (a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND + (b) there is exactly ONE real user profile, AND + (c) that profile was created within $OobeProfileMaxAgeMinutes minutes. + Any miss keeps the original skip (SKIP-safe: we never install on uncertainty). + .PARAMETER AgentDownloadUrl - URL to download the agent ZIP from. Defaults to the production blob; override only - for parallel lab/dev assignments that point at a separate pre-release ZIP. + URL to download the agent ZIP from. Defaults to the production download endpoint; + override only for parallel lab/dev assignments that point at a separate pre-release ZIP. .PARAMETER VersionJsonName Filename of the integrity manifest in the same blob container as the agent ZIP. @@ -27,16 +37,35 @@ valid. Devices booted more than this many hours ago are skipped because we no longer trust their OOBE state. Default: 12. +.PARAMETER OobeProfileMaxAgeMinutes + Maximum age (minutes) of the single profile that may appear during OOBE for the + OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an + older profile is treated as a productive device. Default: 15. + .NOTES - Agent is temporary and auto-removes after enrollment. - All files live under C:\ProgramData\AutopilotMonitor (easy cleanup). - One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs on re-Autopilot of the same device). - Scheduled Task survives reboots during enrollment. + - OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. + Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack + the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs: + https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo - This script MUST remain pure ASCII (no Unicode/UTF-8 special chars). PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. .CHANGELOG + 2026-07-20 v2.2 Switched default download endpoint from the legacy blob + (autopilotmonitor.blob.core.windows.net) to the Front Door alias + https://download.autopilotmonitor.com/agent/ (route /agent/*). + version.json is still derived from the same container. + 2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT + SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a + single freshly-created profile appears in OOBE (Windows Backup for + Organizations). SKIP-safe when the API is absent (< 1809). + Removed legacy Content-MD5 fallback; SHA-256 manifest verification is + now mandatory (no hash -> bootstrap aborts, no unverified install). 2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s TenantId-wait), so the script only calls `--install` plain. Hardened post-install with a 10 s runtime-process verify. @@ -55,17 +84,20 @@ [CmdletBinding()] param( [Parameter(Mandatory = $false)] - [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip", + [string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip", [Parameter(Mandatory = $false)] [string]$VersionJsonName = "version.json", [Parameter(Mandatory = $false)] - [int]$MaxBootstrapWindowHours = 12 + [int]$MaxBootstrapWindowHours = 12, + + [Parameter(Mandatory = $false)] + [int]$OobeProfileMaxAgeMinutes = 15 ) # Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "2.0" +$ScriptVersion = "2.2" # Configuration - Everything in ProgramData for easy cleanup $AgentBasePath = "$env:ProgramData\AutopilotMonitor" @@ -85,26 +117,19 @@ function Write-Log { Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } -function Get-FileMd5Base64 { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - $md5 = [System.Security.Cryptography.MD5]::Create() +# Positive OOBE check via the documented WinRT API +# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState. +# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted), +# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false +# -> SKIP-safe (we never relax a guard on uncertainty). +# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo +function Test-OobeInProgress { try { - $stream = [System.IO.File]::OpenRead($Path) - try { - $hashBytes = $md5.ComputeHash($stream) - } - finally { - $stream.Dispose() - } - - return [Convert]::ToBase64String($hashBytes) + $null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime] + return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress' } - finally { - $md5.Dispose() + catch { + return $false } } @@ -119,30 +144,65 @@ try { exit 0 } - # Guard 2: No real user profile should exist yet (primary productive-device guard). - # Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability. - $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$' + # Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem). + $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$' - $profileNames = @( + # 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 { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } + + (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName + ) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } | + Select-Object -Unique + ) + + # OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for + # Organizations restore (one user signs in + restores DURING OOBE). All three must hold: + # OOBE InProgress AND exactly one profile AND that profile created < N minutes ago. + $oobeRelax = $false + $oobeInProgress = Test-OobeInProgress + if ($profilePaths.Count -eq 1 -and $oobeInProgress) { try { - Get-CimInstance Win32_UserProfile -ErrorAction Stop | - Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } | - ForEach-Object { Split-Path $_.LocalPath -Leaf } - } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } + $created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc + $ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes + if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) { + $oobeRelax = $true + Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes) + } else { + Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes) + } + } catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" } + } elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) { + Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)." + } elseif ($profilePaths.Count -gt 1) { + Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count) + } - (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name - ) | Where-Object { $_ -and $_ -notmatch $excludePattern } | - Select-Object -Unique - - if ($profileNames) { - $names = ($profileNames | Select-Object -First 3) -join ', ' - Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive." + # Guard 2: No real user profile should exist yet (primary productive-device guard), + # unless the OOBE-relax signature holds. + if ($profilePaths.Count -gt 0 -and -not $oobeRelax) { + $details = (($profilePaths | ForEach-Object { + $leaf = Split-Path $_ -Leaf + try { + $c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc + "{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes + } catch { "$leaf (age unknown)" } + }) | Select-Object -First 3) -join '; ' + Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive." exit 0 } - # Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet. + # Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet + # (same OOBE-relax exemption: the restoring user signs in during OOBE). $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser - if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') { + if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) { Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." exit 0 } @@ -166,7 +226,7 @@ try { } } - Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window" + Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)" # Download and extract agent binaries $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" @@ -181,38 +241,47 @@ try { # Derive manifest URL from the agent download URL (same blob container) $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName $expectedSha256 = $null + $manifestAttempts = 3 + $manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment - try { - Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..." - $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop - if ($versionJsonResponse.sha256) { - $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() - Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))" - } else { - Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)" + for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) { + try { + Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..." + $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($versionJsonResponse.sha256) { + $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() + Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))" + } else { + Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified." + } + break # manifest reached (with or without hash); retrying would not help + } + catch { + Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)" + if ($attempt -lt $manifestAttempts) { + Start-Sleep -Seconds $manifestDelays[$attempt - 1] + } } - } - catch { - Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)" } $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" $maxDownloadAttempts = 3 $downloadAttempt = 0 - $downloadResponse = $null do { $downloadAttempt++ try { Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" - $downloadResponse = Invoke-WebRequest ` + $downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + Invoke-WebRequest ` -Uri $AgentDownloadUrl ` -OutFile $zipPath ` -UseBasicParsing ` -TimeoutSec 30 ` - -ErrorAction Stop ` - -PassThru - Write-Log "Downloaded agent to $zipPath" + -ErrorAction Stop + $downloadStopwatch.Stop() + $downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1) + Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)" break } catch { @@ -227,29 +296,17 @@ try { } } while ($downloadAttempt -lt $maxDownloadAttempts) - if ($expectedSha256) { - $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - Write-Log "Validating SHA-256 hash against manifest" - if ($actualSha256 -ne $expectedSha256) { - throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." - } - Write-Log "SHA-256 integrity check passed" + # SHA-256 from the version manifest is the only accepted integrity proof. + # No hash -> refuse to install an unverified ZIP (SKIP-safe security posture). + if (-not $expectedSha256) { + throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP." } - else { - $expectedMd5Header = $downloadResponse.Headers["Content-MD5"] - $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() } - if ($expectedMd5 -notmatch '\S') { - Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation" - } - else { - $actualMd5 = Get-FileMd5Base64 -Path $zipPath - Write-Log "Validating Content-MD5 header against downloaded ZIP" - if ($actualMd5 -ne $expectedMd5) { - throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'" - } - Write-Log "MD5 integrity check passed" - } + $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Log "Validating SHA-256 hash against manifest" + if ($actualSha256 -ne $expectedSha256) { + throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." } + Write-Log "SHA-256 integrity check passed" Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force Write-Log "Extracted agent to $AgentBinPath" diff --git a/scripts/Bootstrap/Test-ShouldBootstrapAgent-dev.ps1 b/scripts/Bootstrap/Test-ShouldBootstrapAgent-dev.ps1 deleted file mode 100644 index 706ff49d..00000000 --- a/scripts/Bootstrap/Test-ShouldBootstrapAgent-dev.ps1 +++ /dev/null @@ -1,213 +0,0 @@ -# Autopilot Monitor Bootstrap Guard Test (DEV) -# Dry-run only: does not exit, does not install, only reports what WOULD happen. -# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the -# "Windows Backup for Organizations" restore case (guards 2+3). - -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" -} - -function Get-RegistryValueSafe { - param( - [string]$Path, - [string]$Name - ) - - try { - return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name - } - catch { - return $null - } -} - -# 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 - } -} - -$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, DEV) ===' -Write-Host '' - -# Guard 1: Agent was already deployed on this device (survives self-destruct) -$deployed = Get-RegistryValueSafe -Path 'HKLM:\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 } - -# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for -# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh. -$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 -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) { - $oobeRelax = $true - } -} - -Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-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 ', ' - Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- 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-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).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 OOBE-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) { - 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.' -} - -# Guard 5: Agent binary already present -if (Test-Path $AgentExePath) { - Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'." - $wouldInstall = $false - $reasons.Add("Agent binary already present: $AgentExePath") -} else { - Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'." -} - -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 "SingleProfileAgeMinutes : $profileAgeMin" -Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes" -Write-Host "OobeRelaxActive : $oobeRelax" -Write-Host "LastLoggedOnUser : $lastLoggedOnUser" -Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours" -Write-Host "LastBootUpTime : $lastBoot" -Write-Host "UptimeHours : $([string]$uptimeHours)" -Write-Host "AgentExePath : $AgentExePath" -Write-Host '' diff --git a/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 b/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 index d22a0b3c..27a21c61 100644 --- a/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 +++ b/scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1 @@ -1,8 +1,11 @@ # Autopilot Monitor Bootstrap Guard Test # Dry-run only: does not exit, does not install, only reports what WOULD happen. +# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the +# "Windows Backup for Organizations" restore case (guards 2+3). param( - [string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent' + [string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent', + [int]$OobeProfileMaxAgeMinutes = 15 ) $ErrorActionPreference = 'SilentlyContinue' @@ -38,6 +41,19 @@ function Get-RegistryValueSafe { } } +# 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 + } +} + $AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe' $MaxBootstrapWindowHours = 12 @@ -58,63 +74,79 @@ if ($deployed) { Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.' } -# Guard 2: No real user profile should exist yet (primary productive-device guard) -# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment). -$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$' +# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view). +$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$' $wmiProfileQueryFailed = $false -$profileNames = @( - # WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService - try { - Get-CimInstance Win32_UserProfile -ErrorAction Stop | - Where-Object { - -not $_.Special -and - $_.LocalPath -like 'C:\Users\*' - } | - ForEach-Object { - Split-Path $_.LocalPath -Leaf - } - } - catch { - $wmiProfileQueryFailed = $true - } +# 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 + } - # Filesystem view -- catches profiles WMI might miss - (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name -) | -Where-Object { - $_ -and $_ -notmatch $excludePattern -} | -Select-Object -Unique + (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 'Guard 2: WMI profile query failed, filesystem check still applied.' + Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.' } -if ($profileNames) { +$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf } + +# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for +# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh. +$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 -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) { + $oobeRelax = $true + } +} + +Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-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 ', ' + Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking." } else { Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).' } -# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet +# Guard 3: LastLoggedOnUser (unless OOBE-relax holds) $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser -if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') { +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 OOBE-relax active -- not blocking." } else { Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')." } -# Guard 4: Bootstrap window check -# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break). -# This is "how old can the OOBE state be before I no longer trust it for initial install". -# 12h bootstrap window vs 6h agent emergency break = consistent layered approach. -# Sleep/standby does NOT reset uptime -- only real boot/restart does. +# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does) $lastBoot = $null $uptimeHours = $null @@ -134,7 +166,7 @@ catch { Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.' } -# Guard 4: Agent binary already present +# Guard 5: Agent binary already present if (Test-Path $AgentExePath) { Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'." $wouldInstall = $false @@ -168,6 +200,11 @@ 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 "SingleProfileAgeMinutes : $profileAgeMin" +Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes" +Write-Host "OobeRelaxActive : $oobeRelax" Write-Host "LastLoggedOnUser : $lastLoggedOnUser" Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours" Write-Host "LastBootUpTime : $lastBoot"