Fix first-logon provisioning never starting

- Bound every installer wait with WaitForExit and a timeout instead of
  Start-Process -Wait, which in Windows PowerShell also waits for any
  process the installer leaves running and can block forever. This
  covers the VirtIO step in the answer files and the guest tools, Mesh
  agent and Winget installs in the scripts.
- specialize pass (SYSTEM) sets ConsentPromptBehaviorAdmin=0 so nothing
  at first logon can stall on a UAC prompt.
- Step 4 now logs to C:\ProgramData\OOBE\firstlogon.log, saves the
  script to C:\ProgramData\OOBE and starts it elevated in its own window.
- Scripts start their transcript before the admin check, so an early
  stop still leaves a log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
master
Chris Nutter 2026-09-21 22:01:11 -07:00
parent 713463cd94
commit f208e07d72
4 changed files with 102 additions and 28 deletions

View File

@ -3,12 +3,6 @@
# irm https://url.isworking.fyi/oobe-desktop | iex
# Log: C:\ProgramData\OOBE\oobe-desktop.log
# Check if running with elevated privileges
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "Please run this script as an administrator."
Exit 1
}
$MeshAgentUrl = "https://rmm.iamchrisama.com/meshagents?id=4&meshid=zxl@U2zM95zh9ZNqah@9mUEjCJ3ptGOE6s5cjsGniacVU1fjRXtVKCKlKJN4aJQW&installflags=0"
$ErrorActionPreference = 'Continue'
@ -21,6 +15,13 @@ $WorkDir = Join-Path $env:ProgramData 'OOBE'
New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
Start-Transcript -Path (Join-Path $WorkDir 'oobe-desktop.log') -Append | Out-Null
# Check if running with elevated privileges (after the transcript starts, so this still leaves a log)
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning "Not running as administrator, stopping. Rerun from an elevated PowerShell."
Stop-Transcript | Out-Null
Exit 1
}
function Save-Download($Uri, $OutFile) {
for ($Attempt = 1; $Attempt -le 3; $Attempt++) {
try {
@ -110,7 +111,10 @@ if (Get-Service -Name 'Mesh Agent' -ErrorAction SilentlyContinue) {
$MeshInstaller = Join-Path $WorkDir 'meshagent.exe'
if (Save-Download $MeshAgentUrl $MeshInstaller) {
# -fullinstall installs and starts the service without showing the install dialog
Start-Process -FilePath $MeshInstaller -ArgumentList '-fullinstall' -WindowStyle Hidden -Wait
$Process = Start-Process -FilePath $MeshInstaller -ArgumentList '-fullinstall' -WindowStyle Hidden -PassThru
if (-not $Process.WaitForExit(300000)) {
Write-Warning "Mesh Agent installer still running after 5 minutes, continuing."
}
}
if (-not (Get-Service -Name 'Mesh Agent' -ErrorAction SilentlyContinue)) {
Write-Warning "Mesh Agent service was not found after install."
@ -140,10 +144,17 @@ if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyCon
}
# Install Winget (LTSC ships without the Store / App Installer)
# Run in a child process: the installer script calls exit, which would end this script too
# Run in a child process (the installer script calls exit, which would end this script too),
# with a time limit so it can't hold up the restart. Its output goes to winget-install*.log.
$WingetInstaller = Join-Path $WorkDir 'winget-install.ps1'
if (Save-Download 'https://github.com/asheroto/winget-install/releases/latest/download/winget-install.ps1' $WingetInstaller) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $WingetInstaller -Force
$Process = Start-Process powershell.exe -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $WingetInstaller, '-Force' -PassThru `
-RedirectStandardOutput (Join-Path $WorkDir 'winget-install.log') -RedirectStandardError (Join-Path $WorkDir 'winget-install-errors.log')
if ($Process.WaitForExit(900000)) {
Write-Output "Winget installer exited with code $($Process.ExitCode)"
} else {
Write-Warning "Winget installer still running after 15 minutes, continuing."
}
}
# Restart to finish OpenSSH and driver installation.

View File

@ -3,12 +3,6 @@
# irm https://url.isworking.fyi/oobe-vms | iex
# Log: C:\ProgramData\OOBE\oobe-vms.log
# Check if running with elevated privileges
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "Please run this script as an administrator."
Exit 1
}
$MeshAgentUrl = "https://rmm.iamchrisama.com/meshagents?id=4&meshid=zxl@U2zM95zh9ZNqah@9mUEjCJ3ptGOE6s5cjsGniacVU1fjRXtVKCKlKJN4aJQW&installflags=0"
$ErrorActionPreference = 'Continue'
@ -21,6 +15,13 @@ $WorkDir = Join-Path $env:ProgramData 'OOBE'
New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
Start-Transcript -Path (Join-Path $WorkDir 'oobe-vms.log') -Append | Out-Null
# Check if running with elevated privileges (after the transcript starts, so this still leaves a log)
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning "Not running as administrator, stopping. Rerun from an elevated PowerShell."
Stop-Transcript | Out-Null
Exit 1
}
function Save-Download($Uri, $OutFile) {
for ($Attempt = 1; $Attempt -le 3; $Attempt++) {
try {
@ -75,8 +76,14 @@ if (Get-Service -Name 'QEMU-GA' -ErrorAction SilentlyContinue) {
ForEach-Object { $Store.Add($_) }
$Store.Close()
$Process = Start-Process -FilePath $GuestTools -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru
Write-Output "VirtIO guest tools installer exited with code $($Process.ExitCode) (0 = success, 3010 = reboot required)"
# Wait on the installer only, with a time limit: Start-Process -Wait also waits for anything
# the installer leaves running (agents, services), which can block forever
$Process = Start-Process -FilePath $GuestTools -ArgumentList '/install', '/quiet', '/norestart' -PassThru
if ($Process.WaitForExit(900000)) {
Write-Output "VirtIO guest tools installer exited with code $($Process.ExitCode) (0 = success, 3010 = reboot required)"
} else {
Write-Warning "VirtIO guest tools installer still running after 15 minutes, continuing."
}
}
}
@ -176,7 +183,10 @@ if (Get-Service -Name 'Mesh Agent' -ErrorAction SilentlyContinue) {
$MeshInstaller = Join-Path $WorkDir 'meshagent.exe'
if (Save-Download $MeshAgentUrl $MeshInstaller) {
# -fullinstall installs and starts the service without showing the install dialog
Start-Process -FilePath $MeshInstaller -ArgumentList '-fullinstall' -WindowStyle Hidden -Wait
$Process = Start-Process -FilePath $MeshInstaller -ArgumentList '-fullinstall' -WindowStyle Hidden -PassThru
if (-not $Process.WaitForExit(300000)) {
Write-Warning "Mesh Agent installer still running after 5 minutes, continuing."
}
}
if (-not (Get-Service -Name 'Mesh Agent' -ErrorAction SilentlyContinue)) {
Write-Warning "Mesh Agent service was not found after install."
@ -206,10 +216,17 @@ if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyCon
}
# Install Winget (LTSC ships without the Store / App Installer)
# Run in a child process: the installer script calls exit, which would end this script too
# Run in a child process (the installer script calls exit, which would end this script too),
# with a time limit so it can't hold up the restart. Its output goes to winget-install*.log.
$WingetInstaller = Join-Path $WorkDir 'winget-install.ps1'
if (Save-Download 'https://github.com/asheroto/winget-install/releases/latest/download/winget-install.ps1' $WingetInstaller) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $WingetInstaller -Force
$Process = Start-Process powershell.exe -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $WingetInstaller, '-Force' -PassThru `
-RedirectStandardOutput (Join-Path $WorkDir 'winget-install.log') -RedirectStandardError (Join-Path $WorkDir 'winget-install-errors.log')
if ($Process.WaitForExit(900000)) {
Write-Output "Winget installer exited with code $($Process.ExitCode)"
} else {
Write-Warning "Winget installer still running after 15 minutes, continuing."
}
}
# Restart to finish driver, guest tools and OpenSSH installation.

View File

@ -141,6 +141,25 @@
======================================================================= -->
<settings pass="specialize">
<!-- Runs as SYSTEM before anyone logs on. Lets admins elevate without a
UAC prompt (oobe-*.ps1 sets the same value later), so nothing at
first logon can stall on an elevation prompt nobody is there to
click: the VirtIO installer, or the RunAs launch in step 4. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Elevate admins without a UAC prompt</Description>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
@ -281,25 +300,29 @@
tools (NetKVM is needed before anything can be downloaded). On
physical PCs no drive has the installer and this does nothing. Scans every drive for
the installer rather than assuming a letter, since the VirtIO
disc moves around post-install. -->
disc moves around post-install. Waits at most 15 minutes, so a stuck
installer can never block the next step. -->
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<Description>Install VirtIO guest tools</Description>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-Volume | Where-Object DriveLetter | ForEach-Object { $exe = $_.DriveLetter + ':\virtio-win-guest-tools.exe'; if (Test-Path $exe) { Start-Process $exe -ArgumentList '/install','/quiet','/norestart' -Wait } }"</CommandLine>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-Volume | Where-Object DriveLetter | ForEach-Object { $exe = $_.DriveLetter + ':\virtio-win-guest-tools.exe'; if (Test-Path $exe) { $p = Start-Process $exe -ArgumentList '/install','/quiet','/norestart' -PassThru; [void]$p.WaitForExit(900000) } }"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
<!-- Provisioning: pulls oobe-desktop.ps1 from the repo so the PC always
gets the current version, retrying for ~2.5 minutes while the
network comes up. If that fails, falls back to the copy of
oobe-desktop.ps1 baked into the root of the ISO. The script installs
oobe-desktop.ps1 baked into the root of the ISO. The script is saved to
C:\ProgramData\OOBE and started elevated in its own window,
so this step finishes as soon as it has launched it. The script installs
the MeshCentral agent, applies the power/RDP/SSH settings and
restarts the PC itself, so this must stay the LAST command.
Step log: C:\ProgramData\OOBE\firstlogon.log (download / fallback / launch)
Log: C:\ProgramData\OOBE\oobe-desktop.log -->
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<Description>Run oobe-desktop.ps1 (url.isworking.fyi, else ISO copy)</Description>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $s = $null; for ($i = 1; $i -le 10; $i++) { try { $s = Invoke-RestMethod 'https://url.isworking.fyi/oobe-desktop'; break } catch { Start-Sleep -Seconds 15 } }; if (-not $s) { $f = Get-PSDrive -PSProvider FileSystem | ForEach-Object { Join-Path $_.Root 'oobe-desktop.ps1' } | Where-Object { Test-Path $_ } | Select-Object -First 1; if ($f) { $s = Get-Content -Raw $f } }; if ($s) { Invoke-Expression $s } else { Write-Warning 'oobe-desktop.ps1 not found online or on any drive'; Start-Sleep -Seconds 60 }"</CommandLine>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "$d = Join-Path $env:ProgramData 'OOBE'; New-Item -ItemType Directory -Force $d | Out-Null; Start-Transcript (Join-Path $d 'firstlogon.log') -Append; [Net.ServicePointManager]::SecurityProtocol = 'Tls12'; $s = $null; for ($i = 1; $i -le 10; $i++) { try { $s = irm 'https://url.isworking.fyi/oobe-desktop' -UseBasicParsing; 'Downloaded'; break } catch { 'Attempt ' + $i + ' failed: ' + $_; sleep 15 } }; if (-not $s) { $f = Get-PSDrive -PSProvider FileSystem | % { Join-Path $_.Root 'oobe-desktop.ps1' } | ? { Test-Path $_ } | select -First 1; if ($f) { 'Using ' + $f; $s = Get-Content -Raw $f } }; if ($s) { $p = Join-Path $d 'oobe-desktop.ps1'; Set-Content $p $s; Start-Process powershell -Verb RunAs -ArgumentList ('-NoProfile -ExecutionPolicy Bypass -File ' + $p); 'Started ' + $p } else { Write-Warning 'oobe-desktop.ps1 not found online or on any drive'; sleep 60 }"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>

View File

@ -188,6 +188,25 @@
======================================================================= -->
<settings pass="specialize">
<!-- Runs as SYSTEM before anyone logs on. Lets admins elevate without a
UAC prompt (oobe-*.ps1 sets the same value later), so nothing at
first logon can stall on an elevation prompt nobody is there to
click: the VirtIO installer, or the RunAs launch in step 4. -->
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Elevate admins without a UAC prompt</Description>
<Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
@ -328,25 +347,29 @@
<!-- NetKVM has to be in place before anything can be downloaded, so
the guest tools go on from the ISO first. Scans every drive for
the installer rather than assuming a letter, since the VirtIO
disc moves around post-install. -->
disc moves around post-install. Waits at most 15 minutes, so a stuck
installer can never block the next step. -->
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<Description>Install VirtIO guest tools</Description>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-Volume | Where-Object DriveLetter | ForEach-Object { $exe = $_.DriveLetter + ':\virtio-win-guest-tools.exe'; if (Test-Path $exe) { Start-Process $exe -ArgumentList '/install','/quiet','/norestart' -Wait } }"</CommandLine>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-Volume | Where-Object DriveLetter | ForEach-Object { $exe = $_.DriveLetter + ':\virtio-win-guest-tools.exe'; if (Test-Path $exe) { $p = Start-Process $exe -ArgumentList '/install','/quiet','/norestart' -PassThru; [void]$p.WaitForExit(900000) } }"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
<!-- Provisioning: pulls oobe-vms.ps1 from the repo so the VM always
gets the current version, retrying for ~2.5 minutes while the
network comes up. If that fails, falls back to the copy of
oobe-vms.ps1 baked into the root of the ISO. The script installs
oobe-vms.ps1 baked into the root of the ISO. The script is saved to
C:\ProgramData\OOBE and started elevated in its own window,
so this step finishes as soon as it has launched it. The script installs
the MeshCentral agent, applies the power/RDP/SSH settings and
restarts the VM itself, so this must stay the LAST command.
Step log: C:\ProgramData\OOBE\firstlogon.log (download / fallback / launch)
Log: C:\ProgramData\OOBE\oobe-vms.log -->
<SynchronousCommand wcm:action="add">
<Order>4</Order>
<Description>Run oobe-vms.ps1 (url.isworking.fyi, else ISO copy)</Description>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $s = $null; for ($i = 1; $i -le 10; $i++) { try { $s = Invoke-RestMethod 'https://url.isworking.fyi/oobe-vms'; break } catch { Start-Sleep -Seconds 15 } }; if (-not $s) { $f = Get-PSDrive -PSProvider FileSystem | ForEach-Object { Join-Path $_.Root 'oobe-vms.ps1' } | Where-Object { Test-Path $_ } | Select-Object -First 1; if ($f) { $s = Get-Content -Raw $f } }; if ($s) { Invoke-Expression $s } else { Write-Warning 'oobe-vms.ps1 not found online or on any drive'; Start-Sleep -Seconds 60 }"</CommandLine>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "$d = Join-Path $env:ProgramData 'OOBE'; New-Item -ItemType Directory -Force $d | Out-Null; Start-Transcript (Join-Path $d 'firstlogon.log') -Append; [Net.ServicePointManager]::SecurityProtocol = 'Tls12'; $s = $null; for ($i = 1; $i -le 10; $i++) { try { $s = irm 'https://url.isworking.fyi/oobe-vms' -UseBasicParsing; 'Downloaded'; break } catch { 'Attempt ' + $i + ' failed: ' + $_; sleep 15 } }; if (-not $s) { $f = Get-PSDrive -PSProvider FileSystem | % { Join-Path $_.Root 'oobe-vms.ps1' } | ? { Test-Path $_ } | select -First 1; if ($f) { 'Using ' + $f; $s = Get-Content -Raw $f } }; if ($s) { $p = Join-Path $d 'oobe-vms.ps1'; Set-Content $p $s; Start-Process powershell -Verb RunAs -ArgumentList ('-NoProfile -ExecutionPolicy Bypass -File ' + $p); 'Started ' + $p } else { Write-Warning 'oobe-vms.ps1 not found online or on any drive'; sleep 60 }"</CommandLine>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>