Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)

MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.

- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
  deferred SYSTEM action, started in System32, reads the copy once, checks our signature
  and the publisher pin on those bytes and runs those bytes from memory, so a file a user
  planted at that path leads to nothing and no handle keeps the installer from removing
  the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
  agent-present branch (install and skip clear the base already) and an agent folder that
  is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
  in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
  asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
  read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
  agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
  directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
  marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-23 02:01:15 +02:00
parent e5b7003799
commit f44e8aadb0
8 changed files with 543 additions and 35 deletions
@@ -70,8 +70,8 @@ function global:HarnessInvokeRestMethod {
function global:HarnessGetAuthenticodeSignature {
[CmdletBinding()]
param([string]$FilePath)
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
return [pscustomobject]@{
Status = $global:HarnessOptions.SignatureStatus
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
@@ -188,8 +188,11 @@ if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'Harness
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
# The OOBE script arrives as irm | iex, which ignores #Requires.
if ($global:HarnessOptions.Iex) {
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
# ignores #Requires.
if ($global:HarnessOptions.CommandText) {
Invoke-Expression $global:HarnessOptions.CommandText
} elseif ($global:HarnessOptions.Iex) {
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
} else {
& $global:HarnessOptions.Script @args
@@ -228,6 +231,7 @@ exit $LASTEXITCODE
Root = $root
ProgramData = Join-Path $root 'ProgramData'
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
ProgramFiles = Join-Path $root 'ProgramFiles'
Temp = Join-Path $root 'Temp'
Server = Join-Path $root 'Server'
Outside = Join-Path $root 'Outside'
@@ -262,9 +266,11 @@ exit $LASTEXITCODE
CatalogValid = $true
Elevated = $true
Iex = $false
CommandText = $null
AgentStartFails = $false
}
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
$script:promiseScenario = $Scenario
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
$settings.SwapDacl = -not $script:isElevated
@@ -287,6 +293,7 @@ exit $LASTEXITCODE
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
$process = [System.Diagnostics.Process]::Start($psi)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
@@ -328,6 +335,25 @@ exit $LASTEXITCODE
return ($sorted -join "`n")
}
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
# ours anywhere else. The scenario's own fixture folders are the only other entries.
function Assert-NothingOutsideProgramData {
param($Scenario)
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
}
# Every Describe runs this after each test: the last run of the test kept the promise.
function Assert-PromiseOfLastRun {
if (-not $script:promiseScenario) { return }
$scenario = $script:promiseScenario
$script:promiseScenario = $null
Assert-NothingOutsideProgramData $scenario
}
function Get-StdErrLines {
param($Run)
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
@@ -369,6 +395,7 @@ exit $LASTEXITCODE
}
Describe 'loader 1.4' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs and leaves only the agent folder, with its log carried over' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
@@ -471,7 +498,7 @@ Describe 'loader 1.4' {
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.8"', '$ScriptVersion = "2.7"') }
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
) {
$s = New-Scenario
@@ -502,6 +529,7 @@ Describe 'loader 1.4' {
}
Describe 'installer assigned directly' {
AfterEach { Assert-PromiseOfLastRun }
BeforeEach {
$s = New-Scenario
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
@@ -605,6 +633,7 @@ Describe 'installer assigned directly' {
}
Describe 'loader 1.2, frozen' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loader12Path
@@ -644,6 +673,7 @@ Describe 'loader 1.2, frozen' {
}
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
AfterEach { Assert-PromiseOfLastRun }
It '<Name> skips and leaves <Left>' -TestCases @(
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@@ -660,6 +690,26 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
# installer takes it away in every outcome, also when an agent is present.
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
$s = New-Scenario
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData -Content | Should -Be $before
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
}
It '<Name> installs' -TestCases @(
@@ -678,10 +728,123 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
}
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
# System32: read the copy once, check our signature on those bytes, run those bytes. The
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
$script:actionCommand = $Matches[1]
function Format-MsiAction {
param([string]$Text, [string]$InstallFolder)
return $Text.Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
}
function Add-MsiCopy {
param($Scenario)
$msi = Join-Path $Scenario.Base 'Msi'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
}
function Invoke-MsiAction {
param($Scenario, [hashtable]$Options = @{})
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
}
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
}
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
# Without the [\x] escapes, the only bracket left is the one property reference.
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
$errors = $null
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
@($errors) | Should -BeNullOrEmpty
}
It 'installs, and the copy goes with the emptied agent folder' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
}
It 'skips and leaves nothing behind' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
}
It 'leaves nothing behind when only the deployment marker is there' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
}
It 'removes the copy and leaves the folder of an agent as it is' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
}
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' } }
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' } }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' } }
) {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options $Options
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
}
}
Describe 'OOBE script, as the backend generates it' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
$s = New-Scenario
Add-Leftovers $s
@@ -733,6 +896,7 @@ Describe 'OOBE script, as the backend generates it' {
}
Describe 'publish pipeline and dev channel' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$script:rendered = Join-Path $TestDrive 'rendered'
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
@@ -768,9 +932,9 @@ Describe 'publish pipeline and dev channel' {
}
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
) {
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
@@ -817,7 +981,7 @@ Describe 'publish pipeline and dev channel' {
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
}
}
@@ -800,6 +800,153 @@ Describe 'Remove-AgentBaseOnSkip' {
}
}
Describe 'Remove-MsiTransportFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
}
It 'has nothing to do without an agent folder or without the copy' {
Remove-MsiTransportFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
}
It 'removes the copy and keeps an agent folder that holds anything else' {
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
Test-Path $MsiTransportPath | Should -BeFalse
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
}
It 'removes the copy and with it an agent folder that is then empty' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
Test-Path $AgentBasePath | Should -BeFalse
}
It 'leaves a copy that is not ours, and says so' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'touches nothing below an agent folder that is not ours' {
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
Mock Test-IsOwnedByUs { $false }
Remove-MsiTransportFolder | Should -BeNullOrEmpty
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
}
It 'never follows a link planted as the copy' {
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
$target = New-SentinelJunction -Link $MsiTransportPath
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-MsiTransportFolder } | Should -Not -Throw
}
}
Describe 'Remove-LegacyMsiLoaderFolder' {
BeforeEach {
Mock Start-Sleep { }
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
Mock Test-IsOwnedByUs { $true }
$script:realProgramW6432 = $env:ProgramW6432
$script:realProgramFiles = $env:ProgramFiles
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
}
AfterEach {
$env:ProgramW6432 = $script:realProgramW6432
$env:ProgramFiles = $script:realProgramFiles
}
It 'has nothing to do where MSI 1.3 never ran' {
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
Test-Path $script:company | Should -BeTrue
}
It 'removes what MSI 1.3 installed, and the company folder with it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
Test-Path $script:company | Should -BeFalse
}
It 'keeps the company folder when anything else is in it' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
Test-Path (Split-Path $script:loader) | Should -BeFalse
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
}
It 'reads the 64-bit Program Files, also from the 32-bit host' {
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
New-Item -ItemType File -Path $script:loader -Force | Out-Null
New-Item -ItemType File -Path $other -Force | Out-Null
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
Test-Path $script:company | Should -BeFalse
Test-Path $other | Should -BeTrue
}
It 'leaves a folder that is not ours' {
New-Item -ItemType File -Path $script:loader -Force | Out-Null
Mock Test-IsOwnedByUs { $false }
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
Test-Path $script:loader | Should -BeTrue
}
It 'never follows a link' {
$target = New-SentinelJunction -Link $script:company
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
}
It 'never throws' {
Mock Get-FolderState { throw 'unexpected' }
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
}
}
Describe 'Get-AgentPayloadRejections' {
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
@@ -1122,7 +1269,7 @@ Describe 'script text' {
It 'declares the role and version the loader checks, one line each' {
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.8"\s*$')).Count | Should -Be 1
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
}
It 'never stages anything in the shared temp folder' {
+65 -3
View File
@@ -13,7 +13,8 @@
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
file matches the catalog signed by glueckkanja AG. The agent removes itself when
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
that the device is not installed again. A skipped device without an agent keeps nothing.
that the device is not installed again. A skipped device without an agent keeps nothing,
and the loader copy the bootstrap MSI installs is removed in every outcome.
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
or the install fails; a skip is logged to the script output only. Exit code 0: installed
@@ -53,7 +54,7 @@ param(
)
# The loader checks these two lines before it runs this script.
$ScriptVersion = "2.8"
$ScriptVersion = "2.9"
$ScriptRole = "AutopilotMonitor-Installer"
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
@@ -69,6 +70,9 @@ $LogFile = "$AgentLogPath\bootstrap-script.log"
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
# Where the bootstrap MSI installs its loader copy; this script removes it in every outcome.
$MsiTransportPath = "$AgentBasePath\Msi"
$AgentExeName = "AutopilotMonitor.Agent.exe"
$AgentTaskName = "AutopilotMonitor-Agent"
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
@@ -608,6 +612,57 @@ function Remove-AgentBaseOnSkip {
}
}
# The loader copy the bootstrap MSI installs. It goes whatever the outcome; an agent folder
# that is then empty goes with it (a running agent, or logs kept after enrollment, keep it).
# Returns what was done, or $null when there was nothing to do; never throws.
function Remove-MsiTransportFolder {
try {
if ((Get-FolderState $AgentBasePath) -ne 'Ours') { return $null }
$state = Get-FolderState $MsiTransportPath
if ($state -eq 'Missing') { return $null }
if ($state -ne 'Ours') { return "$MsiTransportPath left as it is ($state)." }
$left = Remove-OwnTree -Path $MsiTransportPath
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
return ("$MsiTransportPath kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
}
if (@([System.IO.Directory]::EnumerateFileSystemEntries($AgentBasePath)).Count -eq 0) {
[System.IO.Directory]::Delete($AgentBasePath, $false)
return "$MsiTransportPath removed, and with it the empty $AgentBasePath."
}
return "$MsiTransportPath removed."
} catch {
return "$MsiTransportPath left as it is ($($_.Exception.Message))."
}
}
# What bootstrap MSI 1.3 installed under Program Files, on devices that still enroll with it.
# Returns what was done, or $null when there was nothing to do; never throws.
function Remove-LegacyMsiLoaderFolder {
try {
$programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
if (-not $programFiles) { return $null }
$company = Join-Path $programFiles 'AutopilotMonitor'
$companyState = Get-FolderState $company
if ($companyState -eq 'Missing') { return $null }
if ($companyState -ne 'Ours') { return "$company left as it is ($companyState)." }
$path = Join-Path $company 'Bootstrap'
$state = Get-FolderState $path
if ($state -eq 'Missing') { return $null }
if ($state -ne 'Ours') { return "$path left as it is ($state)." }
$left = Remove-OwnTree -Path $path
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
return ("$path kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
}
if (@([System.IO.Directory]::EnumerateFileSystemEntries($company)).Count -eq 0) {
[System.IO.Directory]::Delete($company, $false)
return "$path removed, and with it the empty $company."
}
return "$path removed."
} catch {
return "The loader folder of bootstrap MSI 1.3 is left as it is ($($_.Exception.Message))."
}
}
function Test-IsLoaderRunFolder {
param([string]$Folder)
if (-not $Folder) { return $false }
@@ -711,12 +766,17 @@ function Invoke-Bootstrap {
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
$agent = Get-AgentPresence
if ($agent) {
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
$transport = Remove-MsiTransportFolder
Write-Log "Agent present ($agent): $(if ($transport) { $transport } else { "$AgentBasePath is left as it is." })"
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
if (-not $decision.Install) {
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
exit 0
}
@@ -729,6 +789,8 @@ function Invoke-Bootstrap {
Write-LoaderLogs $loaderLogs
Open-LogFile
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
$legacy = Remove-LegacyMsiLoaderFolder
if ($legacy) { Write-Log $legacy }
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
$agentExePath = Join-Path $AgentBinPath $AgentExeName
@@ -467,11 +467,15 @@ Describe 'publish contract' {
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
}
# The file a SYSTEM custom action runs must not sit where a standard user could have
# created the folder first: Program Files, never ProgramData.
It 'is installed by the bootstrap MSI under Program Files' {
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
# the installer removes it. A standard user could have created that folder first, so the
# action never runs the file as such: it checks the signature on the bytes it read and runs
# those bytes, from System32.
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
$wxs | Should -Match '<StandardDirectory Id="ProgramFiles64Folder">'
$wxs | Should -Not -Match 'CommonAppDataFolder'
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
$wxs | Should -Not -Match 'ProgramFiles'
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
}
}