mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
bootstrap: promote Windows 365 Cloud PC relax to v2.3 production script
Install-AutopilotMonitor.ps1 takes over the field-tested v2.3-dev.3 logic unchanged (code-only diff vs dev: ScriptVersion + log file name); comments condensed to customer-facing descriptions, dev changelog collapsed into one v2.3 entry. Pester suite moved to Install-AutopilotMonitor.Tests.ps1 (neutral test user names), dev script and dev suite removed. Test-ShouldBootstrapAgent dry-run tester mirrors the Cloud PC trigger and the guard-4 uptime exemption. Pester 29/29 green under PS 5.1 and pwsh 7; parse + pure-ASCII checks pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,523 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DEV bootstrap script (v2.3-dev) to deploy and start the Autopilot Monitor agent.
|
||||
|
||||
.DESCRIPTION
|
||||
DEV VARIANT of Install-AutopilotMonitor.ps1 for validating the v2.3 Cloud-PC-relax
|
||||
on Windows 365 before promoting it to the production script. Differences to v2.2:
|
||||
- Cloud-PC-relax (see below) as a second trigger for the guard 2+3 exemption
|
||||
- Raw OutOfBoxExperienceState value is logged (diagnosis)
|
||||
- Logs to bootstrap_agent_dev.log so runs never interleave with the prod script
|
||||
- Testable structure: guard/relax logic lives in functions; the script only
|
||||
executes when run directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor-dev.Tests.ps1 does) loads the functions without
|
||||
side effects.
|
||||
|
||||
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.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
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.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress (Test via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState).
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OutOfBoxExperienceState is NEVER
|
||||
InProgress when this script runs. The assigned user's profile is created at
|
||||
first connect, typically seconds before IME pulls this script. Trigger: the
|
||||
device positively identifies as a Cloud PC via TWO local markers (both
|
||||
required; captured from a real Cloud PC -- local WMI reports only the
|
||||
generic 'Virtual Machine' model): HKLM\SOFTWARE\Microsoft\Windows365
|
||||
registry key AND the CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours for why boot uptime is meaningless there.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
On productive devices the profile is old, so (b) keeps skipping correctly.
|
||||
Known accepted edge: a W365 Frontline shared-mode device can legitimately show a
|
||||
single fresh profile while productive; a mis-install there is one-time (agent
|
||||
finds enrollment complete and self-destructs; Deployed marker prevents repeats).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
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.
|
||||
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.
|
||||
Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs
|
||||
headless for days between provisioning and the user's first connect, and the
|
||||
phase worth monitoring (Account Setup) only starts at that first connect --
|
||||
there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor,
|
||||
not boot uptime.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> 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 -> state reads as 'Unavailable' -> 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-08-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is
|
||||
active: Cloud PCs run headless for days before the user's first
|
||||
connect, so boot uptime says nothing about enrollment freshness --
|
||||
the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already
|
||||
anchors the relax in time. OOBE-restore keeps the uptime guard.
|
||||
2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field
|
||||
test proved local WMI reports only Manufacturer='Microsoft
|
||||
Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model
|
||||
string exists only in Intune/Graph). Detection now requires the
|
||||
HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service (both verified present on a
|
||||
real Cloud PC); raw identity + marker states are always logged.
|
||||
2026-08-05 v2.3-dev DEV variant (-dev.ps1, own log file bootstrap_agent_dev.log)
|
||||
to validate on Windows 365 before promotion to v2.3:
|
||||
Cloud-PC-relax -- a positively identified Cloud PC
|
||||
(Win32_ComputerSystem Manufacturer 'Microsoft Corporation' +
|
||||
Model 'Cloud PC*') now triggers the guard 2+3 relax alongside
|
||||
OOBE InProgress; the single-fresh-profile conditions are
|
||||
unchanged. Raw OutOfBoxExperienceState is now logged.
|
||||
Refactored into functions with a dot-source entry guard so the
|
||||
Pester suite (*.Tests.ps1) can load the logic without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
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.
|
||||
(older entries: see Install-AutopilotMonitor.ps1)
|
||||
#>
|
||||
|
||||
[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.3-dev.3"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent_dev.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return
|
||||
# values and turn them into arrays. Write-Host goes to the console host only.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
|
||||
}
|
||||
catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection.
|
||||
# Evidence captured 2026-08-06 on a real, freshly provisioned W365 Enterprise Cloud PC:
|
||||
# - Win32_ComputerSystem is GENERIC there: Manufacturer='Microsoft Corporation',
|
||||
# Model='Virtual Machine'. The 'Cloud PC Enterprise ...' model string exists only
|
||||
# in the Intune/Graph inventory, NOT in local WMI/SMBIOS -- it cannot be used here.
|
||||
# - HKLM:\SOFTWARE\Microsoft\Windows365 exists.
|
||||
# - Service 'CloudManagedDesktopExtension' (Microsoft Cloud Managed Desktop
|
||||
# Extension, the W365 management agent that runs ON Cloud PCs) is installed.
|
||||
# BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
# physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
# served other managed-desktop offerings). Raw identity and both marker states are
|
||||
# always logged for field diagnosis; any error returns $false (SKIP-safe).
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
|
||||
}
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
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
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
|
||||
|
||||
$relaxActive = $false
|
||||
$oobeState = Get-OobeState
|
||||
$oobeInProgress = ($oobeState -eq 'InProgress')
|
||||
$isCloudPc = Test-IsCloudPc
|
||||
if ($isCloudPc) {
|
||||
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
|
||||
}
|
||||
|
||||
$relaxTrigger = $null
|
||||
if ($oobeInProgress) {
|
||||
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
|
||||
} elseif ($isCloudPc) {
|
||||
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
|
||||
}
|
||||
|
||||
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
|
||||
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) {
|
||||
$relaxActive = $true
|
||||
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($ProfilePaths.Count -eq 1) {
|
||||
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
|
||||
} elseif ($ProfilePaths.Count -gt 1) {
|
||||
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Active = $relaxActive
|
||||
OobeState = $oobeState
|
||||
IsCloudPc = $isCloudPc
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
)
|
||||
|
||||
# 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."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$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 state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$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 $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the
|
||||
# user's first connect -- often days -- and the phase worth monitoring (Account Setup)
|
||||
# only STARTS at that first connect. Boot uptime is therefore meaningless there; the
|
||||
# single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time
|
||||
# anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical
|
||||
# device, uptime is meaningful).
|
||||
$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) {
|
||||
if ($relax.Active -and $relax.IsCloudPc) {
|
||||
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
|
||||
} else {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
# 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)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
try {
|
||||
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
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
if (-not $decision.Install) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor-dev.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
+16
-16
@@ -1,5 +1,5 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
|
||||
# Install-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
@@ -12,7 +12,7 @@
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1')
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
@@ -24,9 +24,9 @@ BeforeAll {
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
|
||||
# is generic ('Virtual Machine'), so detection rests on the Windows365 registry
|
||||
# key AND the CloudManagedDesktopExtension service (both required).
|
||||
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
|
||||
# the Windows365 registry key AND the CloudManagedDesktopExtension service
|
||||
# (both required).
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
|
||||
@@ -78,21 +78,21 @@ Describe 'Get-RealUserProfilePaths' {
|
||||
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
|
||||
Mock Get-CimInstance {
|
||||
@(
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
|
||||
)
|
||||
}
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\LeonGottschalk')
|
||||
$result | Should -Be @('C:\Users\UserOne')
|
||||
}
|
||||
|
||||
It 'falls back to the filesystem view when the WMI query fails' {
|
||||
@@ -132,15 +132,15 @@ Describe 'Get-RelaxDecision' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
}
|
||||
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
|
||||
$p = New-FakeProfile 'CloudPcUser' 0.6
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
$d.IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for an old profile (productive Cloud PC)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 30
|
||||
$p = New-FakeProfile 'CloudPcUser' 30
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ Describe 'Get-RelaxDecision' {
|
||||
}
|
||||
}
|
||||
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' {
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations)' {
|
||||
It 'relaxes for a single fresh profile during OOBE' {
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
$p = New-FakeProfile 'RestoreUser' 3
|
||||
@@ -218,12 +218,12 @@ Describe 'Get-BootstrapDecision' {
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
|
||||
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
|
||||
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
# First-connect user is already visible in LogonUI on W365 -- must not block either.
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\LeonGottschalk' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
@@ -248,7 +248,7 @@ Describe 'Get-BootstrapDecision' {
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' {
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
|
||||
# W365 reality: provisioned days ago, running headless ever since; the user's
|
||||
# first connect just created the profile. Uptime is meaningless here.
|
||||
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
|
||||
@@ -6,8 +6,9 @@
|
||||
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).
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
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
|
||||
@@ -15,14 +16,27 @@
|
||||
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).
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress.
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OOBE is never InProgress when
|
||||
this script runs. The assigned user's profile is created at first connect,
|
||||
typically seconds before Intune pulls this script. Trigger: the device
|
||||
positively identifies as a Cloud PC via TWO local markers (both required):
|
||||
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
|
||||
(SKIP-safe: we never install on uncertainty). On productive devices the
|
||||
profile is old, so (b) keeps skipping correctly.
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
@@ -36,11 +50,14 @@
|
||||
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.
|
||||
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
|
||||
days between provisioning and the user's first connect, so the fresh-profile
|
||||
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
|
||||
|
||||
.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.
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> 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.
|
||||
@@ -50,12 +67,24 @@
|
||||
- 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:
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- Guard/relax logic lives in functions; the script only executes when run
|
||||
directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
|
||||
- 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-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
InProgress; the single-fresh-profile conditions are unchanged
|
||||
and guard 4 (uptime window) is exempted for Cloud PCs.
|
||||
Raw OOBE state and Cloud PC marker states are now logged.
|
||||
Guard/relax logic refactored into functions with a dot-source
|
||||
entry guard so the Pester suite can test it without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
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/*).
|
||||
@@ -97,7 +126,7 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.2"
|
||||
$ScriptVersion = "2.3"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -105,89 +134,151 @@ $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
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Raw OOBE state 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
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> 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 {
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
|
||||
}
|
||||
catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
|
||||
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
|
||||
# so identification uses two W365-specific markers instead. BOTH are required (AND):
|
||||
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
|
||||
# agents). Identity and marker states are logged for diagnosis; any error returns
|
||||
# $false (SKIP-safe).
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
|
||||
}
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
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
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
|
||||
|
||||
$relaxActive = $false
|
||||
$oobeState = Get-OobeState
|
||||
$oobeInProgress = ($oobeState -eq 'InProgress')
|
||||
$isCloudPc = Test-IsCloudPc
|
||||
if ($isCloudPc) {
|
||||
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
|
||||
}
|
||||
|
||||
$relaxTrigger = $null
|
||||
if ($oobeInProgress) {
|
||||
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
|
||||
} elseif ($isCloudPc) {
|
||||
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
|
||||
}
|
||||
|
||||
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
|
||||
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) {
|
||||
$relaxActive = $true
|
||||
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($ProfilePaths.Count -eq 1) {
|
||||
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
|
||||
} elseif ($ProfilePaths.Count -gt 1) {
|
||||
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Active = $relaxActive
|
||||
OobeState = $oobeState
|
||||
IsCloudPc = $isCloudPc
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
)
|
||||
|
||||
# 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
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# 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)
|
||||
}
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# 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) {
|
||||
# unless the relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
@@ -195,26 +286,33 @@ try {
|
||||
"{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
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$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) {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
|
||||
# provisioning until the user's first connect (often days), so boot uptime says
|
||||
# nothing there -- the fresh-profile window of the relax is the time anchor.
|
||||
$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
|
||||
if ($relax.Active -and $relax.IsCloudPc) {
|
||||
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
|
||||
} else {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
@@ -222,148 +320,175 @@ try {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
try {
|
||||
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
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
if (-not $decision.Install) {
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
# 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"
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
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"
|
||||
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)
|
||||
}
|
||||
$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 ====="
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# 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).
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
|
||||
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
|
||||
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active.
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
@@ -54,6 +56,12 @@ function Test-OobeInProgress {
|
||||
}
|
||||
}
|
||||
|
||||
# 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-Path 'HKLM:\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
|
||||
|
||||
@@ -104,8 +112,9 @@ if ($wmiProfileQueryFailed) {
|
||||
|
||||
$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.
|
||||
# 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
|
||||
@@ -114,12 +123,13 @@ if ($profilePaths.Count -eq 1) {
|
||||
$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) {
|
||||
if (($oobeInProgress -or $isCloudPc) -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"
|
||||
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) {
|
||||
@@ -129,7 +139,8 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$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."
|
||||
$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).'
|
||||
}
|
||||
@@ -141,7 +152,7 @@ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -no
|
||||
$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."
|
||||
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')."
|
||||
}
|
||||
@@ -155,9 +166,15 @@ try {
|
||||
$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")
|
||||
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."
|
||||
}
|
||||
@@ -202,9 +219,12 @@ 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 "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "RelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
Reference in New Issue
Block a user