From acc95471a67bccdd5bb9a4bfe7c6763888127d22 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 5 Aug 2026 20:17:10 +0200 Subject: [PATCH] bootstrap(dev): Pester-testable structure + guard/relax test suite + CI Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions (Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision, Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and a dot-source entry guard, so the Pester suite can load the logic without executing the bootstrap. Behaviour when run directly is unchanged. Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output, which turned function return values into arrays and swallowed console output; now Write-Host, guarded by a dedicated logging-purity test. Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection, relax scenarios incl. the W365 field case, guards 1-5, profile collection with WMI failure path) and .github/workflows/pester.yml running the suite under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7. Co-Authored-By: Claude Fable 5 --- .github/workflows/pester.yml | 61 +++ .../Install-AutopilotMonitor-dev.Tests.ps1 | 258 +++++++++++ .../Install-AutopilotMonitor-dev.ps1 | 414 ++++++++++-------- 3 files changed, 555 insertions(+), 178 deletions(-) create mode 100644 .github/workflows/pester.yml create mode 100644 scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml new file mode 100644 index 00000000..1204a221 --- /dev/null +++ b/.github/workflows/pester.yml @@ -0,0 +1,61 @@ +name: Pester (Bootstrap scripts) + +# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1 +# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are +# verified without resetting a device. Like ci.yml it reports rather than blocks. + +on: + push: + branches: [main] + paths: ['scripts/Bootstrap/**'] + pull_request: + branches: [main] + paths: ['scripts/Bootstrap/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pester-${{ github.ref }} + cancel-in-progress: true + +jobs: + pester: + name: Bootstrap guard tests (PS 5.1) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - name: Install Pester 5+ + shell: powershell + run: | + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null + Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0 + + - name: Run Pester + shell: powershell + run: | + Import-Module Pester -MinimumVersion 5.5.0 + $c = New-PesterConfiguration + $c.Run.Path = 'scripts/Bootstrap' + $c.Run.Exit = $true + $c.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $c + + - name: Guard scripts stay pure ASCII + shell: powershell + run: | + $failed = $false + Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object { + $bytes = [System.IO.File]::ReadAllBytes($_.FullName) + for ($i = 0; $i -lt $bytes.Length; $i++) { + if ($bytes[$i] -gt 127) { + Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars." + $failed = $true + break + } + } + } + if ($failed) { exit 1 } + Write-Host 'All bootstrap scripts are pure ASCII.' diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 new file mode 100644 index 00000000..cff4b92c --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 @@ -0,0 +1,258 @@ +# Pester tests (Pester 5+) for the bootstrap guard/relax logic in +# Install-AutopilotMonitor-dev.ps1 (v2.3-dev). +# +# 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, +# registry, filesystem, WinRT) is mocked; profile directories are real folders +# under Pester's $TestDrive with a manipulated CreationTimeUtc. +# +# Run under Windows PowerShell 5.1 to match the IME runtime: +# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }" +# +# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + +BeforeAll { + . (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1') + + function New-FakeProfile { + param([string]$Name, [double]$AgeMinutes) + $p = Join-Path $TestDrive $Name + New-Item -ItemType Directory -Path $p -Force | Out-Null + (Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes) + return $p + } +} + +Describe 'Get-CloudPcModel' { + It 'returns the model string for a Cloud PC' { + Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Cloud PC Enterprise 2vCPU/8GB/128GB' } } + Get-CloudPcModel | Should -Be 'Cloud PC Enterprise 2vCPU/8GB/128GB' + } + + It 'returns null for a physical device' { + Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Dell Inc.'; Model = 'Latitude 7450' } } + Get-CloudPcModel | Should -BeNullOrEmpty + } + + It 'returns null for a non-Microsoft device whose model happens to match' { + Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Contoso Ltd.'; Model = 'Cloud PC Clone' } } + Get-CloudPcModel | Should -BeNullOrEmpty + } + + It 'returns null when the WMI query fails (SKIP-safe)' { + Mock Get-CimInstance { throw 'RPC server unavailable' } + Get-CloudPcModel | Should -BeNullOrEmpty + } +} + +Describe 'Get-OobeState' { + It 'returns a non-empty state string and never throws' { + Get-OobeState | Should -Not -BeNullOrEmpty + } +} + +Describe 'Get-RealUserProfilePaths' { + BeforeEach { + Mock Write-Log { } + } + + It 'merges WMI and filesystem views, drops special/system profiles, dedupes' { + Mock Get-CimInstance { + @( + [pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' } + [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\Public' } + [pscustomobject]@{ FullName = 'C:\Users\defaultuser0' } + [pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' } + ) + } + $result = @(Get-RealUserProfilePaths) + $result | Should -Be @('C:\Users\LeonGottschalk') + } + + It 'falls back to the filesystem view when the WMI query fails' { + Mock Get-CimInstance { throw 'WMI broken' } + Mock Get-ChildItem { + @( + [pscustomobject]@{ FullName = 'C:\Users\SomeUser' } + [pscustomobject]@{ FullName = 'C:\Users\Default' } + ) + } + $result = @(Get-RealUserProfilePaths) + $result | Should -Be @('C:\Users\SomeUser') + } + + It 'returns an empty result when only special/system profiles exist' { + Mock Get-CimInstance { @() } + Mock Get-ChildItem { + @( + [pscustomobject]@{ FullName = 'C:\Users\Public' } + [pscustomobject]@{ FullName = 'C:\Users\defaultuser1' } + ) + } + @(Get-RealUserProfilePaths).Count | Should -Be 0 + } +} + +Describe 'Get-RelaxDecision' { + BeforeEach { + Mock Write-Log { } + # Defaults: physical device, OOBE long done. Individual tests override. + Mock Get-OobeState { 'Completed' } + Mock Get-CloudPcModel { $null } + } + + Context 'Windows 365 Cloud PC' { + BeforeEach { + Mock Get-CloudPcModel { 'Cloud PC Enterprise 2vCPU/8GB/128GB' } + } + + It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' { + $p = New-FakeProfile 'LeonGottschalk' 0.6 + $d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15 + $d.Active | Should -BeTrue + $d.CloudPcModel | Should -Be 'Cloud PC Enterprise 2vCPU/8GB/128GB' + } + + It 'does not relax for an old profile (productive Cloud PC)' { + $p = New-FakeProfile 'LeonGottschalk' 30 + (Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse + } + + It 'does not relax for two profiles' { + $p1 = New-FakeProfile 'UserA' 2 + $p2 = New-FakeProfile 'UserB' 400 + (Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse + } + + It 'relaxes even when the WinRT OOBE API is unavailable (old build)' { + Mock Get-OobeState { 'Unavailable' } + $p = New-FakeProfile 'CpcUser' 1 + (Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue + } + + It 'does not relax for a profile with a future creation time (clock skew)' { + $p = New-FakeProfile 'SkewUser' -10 + (Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse + } + } + + Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' { + It 'relaxes for a single fresh profile during OOBE' { + Mock Get-OobeState { 'InProgress' } + $p = New-FakeProfile 'RestoreUser' 3 + $d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15 + $d.Active | Should -BeTrue + } + } + + Context 'no trigger' { + It 'does not relax on a physical device with a fresh profile and OOBE Completed' { + $p = New-FakeProfile 'SomeUser' 2 + (Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse + } + + It 'stays inactive with no profiles at all' { + (Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse + } + } +} + +Describe 'Get-BootstrapDecision' { + BeforeEach { + Mock Write-Log { } + # Baseline: clean device during Device ESP -- no marker, no profiles, no + # logged-on user, 1h uptime, agent not installed. Tests override selectively. + Mock Get-ItemProperty { $null } + Mock Get-RealUserProfilePaths { @() } + Mock Get-OobeState { 'Completed' } + Mock Get-CloudPcModel { $null } + Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + $script:cleanAgentBin = Join-Path $TestDrive 'AgentBin' + } + + It 'installs on a clean ESP device' { + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeTrue + $d.ReasonCode | Should -BeNullOrEmpty + $d.RelaxActive | Should -BeFalse + } + + It 'skips when the Deployed marker exists (guard 1)' { + Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'AlreadyDeployed' + } + + It 'skips a productive device with an old profile (guard 2)' { + $script:fakeProfile = New-FakeProfile 'OldUser' 5000 + Mock Get-RealUserProfilePaths { @($script:fakeProfile) } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $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 + Mock Get-RealUserProfilePaths { @($script:fakeProfile) } + Mock Get-CloudPcModel { 'Cloud PC Enterprise 2vCPU/8GB/128GB' } + # 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*' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeTrue + $d.RelaxActive | Should -BeTrue + } + + It 'skips when a real user has logged on and no relax applies (guard 3)' { + Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'LastLoggedOnUser' + } + + It 'does not skip for the defaultuser0 OOBE account (guard 3)' { + Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' } + (Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue + } + + It 'skips when uptime exceeds the bootstrap window (guard 4)' { + Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'UptimeExceeded' + } + + It 'skips when the agent binary is already present (guard 5)' { + $binPath = Join-Path $TestDrive 'AgentBinExisting' + New-Item -ItemType Directory -Path $binPath -Force | Out-Null + Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub' + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'AgentPresent' + } +} + +Describe 'logging purity' { + # Regression guard: Write-Log runs inside functions that RETURN decision + # objects. If it ever emits to the output stream again (Write-Output), the + # log lines leak into the return values and turn them into arrays. This + # test deliberately does NOT mock Write-Log. + It 'Get-BootstrapDecision returns exactly one object with real logging' { + Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' } + # Plain local assignment: PowerShell's dynamic scoping makes Write-Log + # (called from Get-BootstrapDecision) resolve this $LogFile instead of + # the ProgramData path defined by the dot-sourced script. + $LogFile = Join-Path $TestDrive 'bootstrap_test.log' + $d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent')) + $d.Count | Should -Be 1 + $d[0].ReasonCode | Should -Be 'AlreadyDeployed' + Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed' + } +} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 index ce18a459..cd70b7e2 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 @@ -8,6 +8,10 @@ - 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: @@ -87,6 +91,9 @@ 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/*). @@ -127,15 +134,14 @@ $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\bootstrap_agent_dev.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 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-Output $logMessage + Write-Host $logMessage Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } @@ -170,41 +176,37 @@ function Get-CloudPcModel { return $null } -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). +# 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." } - # 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 +} - (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), CloudPcModel (string or null). +function Get-RelaxDecision { + param( + [string[]]$ProfilePaths, + [int]$OobeProfileMaxAgeMinutes ) + if ($null -eq $ProfilePaths) { $ProfilePaths = @() } - # 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. $relaxActive = $false $oobeState = Get-OobeState $oobeInProgress = ($oobeState -eq 'InProgress') @@ -220,26 +222,55 @@ try { $relaxTrigger = "Cloud PC '$cloudPcModel' (headless provisioning, OOBE state: $oobeState)" } - if ($profilePaths.Count -eq 1 -and $relaxTrigger) { + if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) { try { - $created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc + $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) + 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) { + } 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, [bool]$cloudPcModel) + } 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, [bool]$cloudPcModel) } + return [pscustomobject]@{ + Active = $relaxActive + OobeState = $oobeState + CloudPcModel = $cloudPcModel + } +} + +# 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 $relaxActive) { + if ($profilePaths.Count -gt 0 -and -not $relax.Active) { $details = (($profilePaths | ForEach-Object { $leaf = Split-Path $_ -Leaf try { @@ -247,16 +278,16 @@ 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 state: $oobeState. CloudPC: $([bool]$cloudPcModel). Device appears productive." - exit 0 + Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $([bool]$relax.CloudPcModel). 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 $relaxActive) { + 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. @@ -268,7 +299,7 @@ try { 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 + return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active } } # Guard 5: Is the agent already installed? (leftover from previous run) @@ -276,148 +307,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 (relaxActive=$relaxActive)" + 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-dev.ps1", as the Pester suite does) only loads +# the functions above and MUST stay side-effect free. +if ($MyInvocation.InvocationName -ne '.') { + Invoke-Bootstrap }