# intune-bench-prep.ps1 — Intune Autopilot bench prep for small device batches # # Source of truth: scripts/intune-bench-prep.ps1 in the MKL repo. # Re-stage to the paste server after editing: # curl -X POST https://mkl.techtoschool.com/p \ # -H "Authorization: Bearer $PASTE_KEY" \ # --data-binary @scripts/intune-bench-prep.ps1 # # Run from audit mode (Ctrl+Shift+F3 at first OOBE) on the target device: # irm mkl.techtoschool.com/p | iex # # Workflow per device (autonomous, ~30-90 min): # 1. Extract Autopilot hardware hash, POST to MKL (first — hash is safe even if later steps fail) # 2. Bootstrap PSWindowsUpdate + self-register scheduled task (resumes across reboots) # 3. Install pending Windows Updates (capped at 3 retries on the same KB set so stuck-pending KBs don't loop forever) # 4. Pre-sysprep Appx cleanup — remove orphan Appx packages installed for user-but-not-all-users (the sysprep killer) # 5. Sysprep /generalize /oobe /shutdown /quiet WITH VERIFICATION — checks Panther\setuperr.log post-invocation; # if errors exist, reports SYSPREP_FAILED and refuses to mark complete (device stays at audit mode for triage). # 'complete' stage is only marked AFTER verification passes, NOT before sysprep is invoked. # # After a clean sysprep the device powers off ready for either: # (a) ship to customer for user-driven OOBE, or # (b) bench-tech pre-provisions at HQ — power on, at first OOBE screen click "Pre-provision device" # (or Win+5 -> "Pre-provision with Windows Autopilot" on older builds). Pre-provisioning requires # "Allow pre-provisioned deployment: Yes" on the customer's Autopilot deployment profile in Intune. # # All progress + errors POST back to https://mkl.techtoschool.com/p/report # Read with: curl -sH "Authorization: Bearer $PASTE_KEY" https://mkl.techtoschool.com/p/reports | jq # # See .claude/docs/autopilot-enrollment.md for full pipeline docs. $ErrorActionPreference = 'Stop' Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue $MKL = 'https://mkl.techtoschool.com' $STATE = "$env:ProgramData\TTS\state" $LOG = "$env:ProgramData\TTS\rondout-prep.log" $BOOTSTRAP = "$env:ProgramData\TTS\bootstrap.ps1" $TASK = 'RondoutPrep' $null = New-Item -Path "$env:ProgramData\TTS" -Force -ItemType Directory $null = New-Item -Path $STATE -Force -ItemType Directory function Get-Serial { try { (Get-CimInstance Win32_BIOS).SerialNumber } catch { 'unknown' } } $SERIAL = Get-Serial function Log { param([string]$m) $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" Add-Content -Path $LOG -Value $line -ErrorAction SilentlyContinue Write-Host $line } function Report { param([string]$tag, [string]$body) $log = if (Test-Path $LOG) { Get-Content $LOG -Raw -ErrorAction SilentlyContinue } else { '' } if ($log.Length -gt 50000) { $log = $log.Substring($log.Length - 50000) } $payload = @{ hostname = "rondout:$SERIAL`:$tag" data = "$body`n`n--- LOG (last 50KB) ---`n$log" } | ConvertTo-Json -Compress try { Invoke-RestMethod -Uri "$MKL/p/report" -Method POST -ContentType 'application/json' ` -Body $payload -TimeoutSec 30 | Out-Null } catch { Write-Host "[Report] failed: $_" } } function Stage-Done { param([string]$s) Test-Path "$STATE\$s" } function Mark-Stage { param([string]$s) New-Item "$STATE\$s" -ItemType File -Force | Out-Null } trap { Log "FATAL: $_" Log "STACK: $($_.ScriptStackTrace)" Report 'ERROR' "FATAL: $_`n$($_.ScriptStackTrace)" break } # Refuse to re-run after sysprep already completed if (Stage-Done 'complete') { Log "Stage 'complete' marker exists — refusing to re-run. Delete $STATE\complete to override." exit 0 } Log "=== rondout-prep starting on $SERIAL (PID $PID) ===" Report 'HEARTBEAT' "Starting (or resuming) on $SERIAL — stages done: $(Get-ChildItem $STATE -ErrorAction SilentlyContinue | ForEach-Object Name | Sort-Object | Out-String)" # ── Self-arm scheduled task to resume after reboots ───────────── @" `$ErrorActionPreference = 'Continue' try { `$content = (Invoke-WebRequest -Uri '$MKL/p' -UseBasicParsing).Content Invoke-Expression `$content } catch { try { `$payload = @{ hostname = "rondout-bootstrap"; data = "Bootstrap fetch failed: `$_" } | ConvertTo-Json Invoke-RestMethod -Uri '$MKL/p/report' -Method POST -ContentType 'application/json' -Body `$payload | Out-Null } catch {} } "@ | Set-Content -Path $BOOTSTRAP -Encoding UTF8 -Force if (-not (Get-ScheduledTask -TaskName $TASK -ErrorAction SilentlyContinue)) { Log "Registering scheduled task '$TASK'..." $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$BOOTSTRAP`"" $trigger = New-ScheduledTaskTrigger -AtStartup $trigger.Delay = 'PT45S' $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable ` -ExecutionTimeLimit (New-TimeSpan -Hours 6) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 2) Register-ScheduledTask -TaskName $TASK -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force | Out-Null } # ── Wait for network ──────────────────────────────────────────── Log "Waiting for network..." $net = $false for ($i = 0; $i -lt 60; $i++) { if (Test-Connection -ComputerName 8.8.8.8 -Count 1 -Quiet -ErrorAction SilentlyContinue) { $net = $true; break } Start-Sleep -Seconds 2 } if (-not $net) { throw "No network connectivity after 2 minutes" } Log "Network up." # ── Stage 1: Extract Autopilot hardware hash + POST to MKL ────── # Done first so the hash is safely uploaded even if later steps fail. if (-not (Stage-Done 'hash')) { Log "Extracting Autopilot hardware hash..." $model = (Get-CimInstance Win32_ComputerSystem).Model $devDetail = Get-CimInstance -Namespace root\cimv2\mdm\dmmap ` -ClassName MDM_DevDetail_Ext01 -Filter "InstanceID='Ext' AND ParentID='./DevDetail'" $hash = $devDetail.DeviceHardwareData if (-not $hash) { throw "DeviceHardwareData empty — TPM not initialized?" } Log "Hash length: $($hash.Length) chars; serial=$SERIAL; model=$model" $body = @{ serial_number = $SERIAL hardware_hash = $hash device_model = $model } | ConvertTo-Json $resp = Invoke-RestMethod -Uri "$MKL/api/autopilot/hash" -Method POST ` -ContentType 'application/json' -Body $body -TimeoutSec 60 Log "Hash uploaded: $($resp | ConvertTo-Json -Compress)" Report 'HASH_UPLOADED' "Hash uploaded for $SERIAL ($model). Response: $($resp | ConvertTo-Json -Compress)" Mark-Stage 'hash' } # ── Stage 2: Bootstrap PSWindowsUpdate ────────────────────────── if (-not (Stage-Done 'bootstrap')) { Log "Bootstrapping NuGet + PSWindowsUpdate..." [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null Install-Module -Name PSWindowsUpdate -Force -SkipPublisherCheck -AllowClobber Mark-Stage 'bootstrap' Log "Bootstrap complete." } Import-Module PSWindowsUpdate -Force # ── Stage 3: Install Windows Updates iteratively (capped at 3 retries on same KB set) ─── # Some KBs (e.g. Defender platform update KB5007651) report as "pending" forever even # after install. Without a cap the bench loops indefinitely. State persisted in # $STATE so the count survives reboots. if (-not (Stage-Done 'updates')) { Log "Scanning for Windows Updates (Microsoft Update catalog)..." $pending = Get-WindowsUpdate -MicrosoftUpdate -ErrorAction SilentlyContinue if ($pending -and $pending.Count -gt 0) { $titles = ($pending | ForEach-Object { " - $($_.Title)" } | Out-String) $kbsKey = ($pending | ForEach-Object { if ($_.KB) { $_.KB } else { $_.Title } } | Sort-Object -Unique) -join ',' $retryKbsFile = "$STATE\update-retry-kbs.txt" $retryCountFile = "$STATE\update-retry-count.txt" $prevKbs = if (Test-Path $retryKbsFile) { (Get-Content $retryKbsFile -Raw -ErrorAction SilentlyContinue).Trim() } else { '' } $prevCount = 0 if (Test-Path $retryCountFile) { try { $prevCount = [int]((Get-Content $retryCountFile -Raw -ErrorAction SilentlyContinue).Trim()) } catch { $prevCount = 0 } } $currentCount = if ($kbsKey -eq $prevKbs) { $prevCount + 1 } else { 1 } Set-Content -Path $retryKbsFile -Value $kbsKey -Force -Encoding UTF8 Set-Content -Path $retryCountFile -Value $currentCount -Force -Encoding UTF8 if ($currentCount -gt 3) { Log "Same KB set pending after $currentCount install attempts — accepting as stuck and proceeding." Log "Stuck KBs: $kbsKey" Report 'WARN' "Update KBs stuck after $currentCount attempts (proceeding to sysprep anyway):`n$titles" Mark-Stage 'updates' } else { Log "Installing $($pending.Count) update(s) [attempt $currentCount/3]:`n$titles" Report 'UPDATING' "Attempt $currentCount/3: Installing $($pending.Count) updates:`n$titles" Install-WindowsUpdate -MicrosoftUpdate -AcceptAll -IgnoreReboot -ErrorAction SilentlyContinue | Out-Null Log "Install pass complete; rebooting to settle and rescan..." Report 'REBOOT' "Install pass complete (attempt $currentCount/3); rebooting to rescan." Start-Sleep 10 Restart-Computer -Force exit 0 } } else { Log "No more updates pending." Mark-Stage 'updates' } } # ── Stage 4: Pre-sysprep Appx cleanup (multi-pass for dependency handling) ─── # Removes Appx packages installed for the current user but NOT provisioned for # all users — the classic "Package was installed for a user, but not provisioned # for all users" sysprep killer (Microsoft.Ink.Handwriting, Lenovo Vantage churn, # etc.). Multi-pass because some packages (e.g. Microsoft.NET.Native.Framework # dependencies) can't be removed until their parent apps are gone — each pass # removes whatever's currently removable, then re-evaluates orphans. if (-not (Stage-Done 'appx-clean')) { Log "Scanning for orphan Appx packages (sysprep killers)..." $maxPasses = 5 $totalRemoved = 0 $stuck = @() for ($pass = 1; $pass -le $maxPasses; $pass++) { $allPackages = Get-AppxPackage -AllUsers -ErrorAction SilentlyContinue $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue $provisionedNames = @($provisioned | ForEach-Object { $_.PackageName }) $orphans = @($allPackages | Where-Object { ($_.NonRemovable -eq $false) -and (-not ($provisionedNames -contains $_.PackageFullName)) -and ($_.Name -notlike 'Microsoft.WindowsStore*') -and ($_.Name -notlike 'Microsoft.DesktopAppInstaller*') }) if ($orphans.Count -eq 0) { Log "Pass $pass`: 0 orphans remaining — appx cleanup complete (removed $totalRemoved across $($pass-1) pass(es))" $stuck = @() break } Log "Pass $pass/$maxPasses`: $($orphans.Count) orphan(s) to remove" $passRemoved = 0 $passFailed = @() foreach ($pkg in $orphans) { try { Remove-AppxPackage -Package $pkg.PackageFullName -AllUsers -ErrorAction Stop $passRemoved++ $totalRemoved++ } catch { $passFailed += $pkg.PackageFullName } } Log "Pass $pass`: removed $passRemoved, $($passFailed.Count) couldn't be removed this pass" $stuck = $passFailed # No progress this pass → giving up (sysprep stage will catch any remaining) if ($passRemoved -eq 0) { Log "WARNING: No progress on pass $pass — $($passFailed.Count) orphan(s) stuck (likely non-removable system packages). Proceeding." break } } if ($stuck.Count -gt 0) { $stuckList = ($stuck | Select-Object -First 10) -join "`n " Report 'APPX_CLEAN' "Removed $totalRemoved orphan(s); $($stuck.Count) stuck:`n $stuckList" } else { Report 'APPX_CLEAN' "Removed $totalRemoved orphan Appx package(s); 0 stuck." } Mark-Stage 'appx-clean' } # ── Stage 5: Sysprep with verification ────────────────────────── # DO NOT mark 'complete' until we've verified sysprep didn't fail. The previous # version marked complete BEFORE invoking sysprep, which let half-baked devices # ship to customers when sysprep silently failed validation. Log "Removing scheduled task before sysprep..." Unregister-ScheduledTask -TaskName $TASK -Confirm:$false -ErrorAction SilentlyContinue $sysprepStart = Get-Date Log "Running sysprep /generalize /oobe /shutdown /quiet..." Report 'SYSPREP' "Launching sysprep for $SERIAL (started $sysprepStart)" & "$env:SystemRoot\System32\Sysprep\sysprep.exe" /generalize /oobe /shutdown /quiet # sysprep.exe returns immediately while validation runs async. If validation # fails, the device will NOT shut down and Panther\setuperr.log will have errors # timestamped after $sysprepStart. Wait briefly then check. Start-Sleep -Seconds 30 $errLog = 'C:\Windows\System32\Sysprep\Panther\setuperr.log' $sysprepErrors = @() if (Test-Path $errLog) { $allLines = Get-Content $errLog -ErrorAction SilentlyContinue foreach ($line in $allLines) { if ($line -match '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})') { try { $logTime = [DateTime]::ParseExact($matches[1], 'yyyy-MM-dd HH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) if (($logTime -gt $sysprepStart) -and ($line -match 'Error')) { $sysprepErrors += $line } } catch {} } } } if ($sysprepErrors.Count -gt 0) { $errSummary = ($sysprepErrors | Select-Object -First 10) -join "`n" Log "SYSPREP FAILED — $($sysprepErrors.Count) error(s) since $sysprepStart" Log $errSummary Report 'SYSPREP_FAILED' "Sysprep validation failed for $SERIAL with $($sysprepErrors.Count) error(s). First 10:`n$errSummary" throw "Sysprep failed — see Panther\setuperr.log on device. Device NOT marked complete; do not ship." } # No errors after start — sysprep is in progress, shutdown will happen Mark-Stage 'complete' Report 'COMPLETE' "Sysprep launched successfully for $SERIAL; awaiting clean shutdown." Log "Sysprep validation passed; device should power off shortly..." # Park here until the OS shuts us down (or alarm if it doesn't) $waitStart = Get-Date while (((Get-Date) - $waitStart).TotalMinutes -lt 15) { Start-Sleep -Seconds 60 Log "Still alive; waiting for sysprep shutdown..." } Report 'WARN' "Sysprep didn't shut down within 15 minutes for $SERIAL — manual check needed."