mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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' {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user