mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1457d823d | |||
| 12e66299e6 | |||
| e9a01de9da | |||
| 5f793ea01a | |||
| e86f7efed3 | |||
| 78bfad0ec6 | |||
| f5efadfd07 | |||
| c26eca2f01 | |||
| 9f1879a9f3 | |||
| c24c7bf7ef | |||
| 5fac3d6004 | |||
| 7605d2e9f5 | |||
| 7bf5412d63 | |||
| b53ceb1a7c | |||
| 647581b4ff | |||
| db9b1995bc | |||
| 7563435837 | |||
| e39f2abb61 | |||
| 47dad7143f | |||
| d81f41dc72 | |||
| cc6be10532 | |||
| 0ac548bfce | |||
| 9c1d0586ef | |||
| 21ec5e4773 | |||
| e4a5a6654d | |||
| 01bc35aa97 | |||
| e33d85c01d | |||
| 4e73ba8900 | |||
| 4df52b3da9 | |||
| 844d86410c | |||
| 9514e1033d | |||
| b2236bf65f | |||
| 31535b07e0 | |||
| 066b6e6f04 | |||
| a1e2b76a9f | |||
| 29e5b8251d | |||
| 02168a5c55 | |||
| a6b6661320 | |||
| 0c72666a7a | |||
| 9023321db9 | |||
| 7ef097529c | |||
| e7df69efe4 | |||
| 2c7efa2733 | |||
| 2742549827 | |||
| 3c45f5cb09 | |||
| d5d1d7bfbd | |||
| f4e2088b69 | |||
| 55425058a8 | |||
| eec0d17607 | |||
| 43f5304ba4 | |||
| b21472dd8c | |||
| e3991d55be | |||
| 864d1ed31e | |||
| 76ef1e53ca | |||
| 7dd298f04e | |||
| 66452621c6 | |||
| 0b23d93957 | |||
| 4281a6d81b | |||
| fe919a51aa | |||
| 0dcf0eb503 | |||
| e2fb99d5d8 | |||
| a3ee613b60 | |||
| d2197c6a57 | |||
| e5997bc50e | |||
| 75138016ce | |||
| fc4772efb0 | |||
| 0bde428fa0 | |||
| 16ca3a059c | |||
| 1bfc80e6db | |||
| fa705a2ef6 | |||
| 81f807e124 | |||
| a4f7c88785 | |||
| 54259f070f | |||
| 26cef3ab06 | |||
| 00ad230ffe | |||
| 3d920a8113 | |||
| f6eac1964a | |||
| e1b5b9d163 | |||
| 094833f831 | |||
| 3579ce0add | |||
| 0188860d1c | |||
| 871a1ac059 | |||
| 298a182fbb | |||
| e71223507c | |||
| 02187f0f3c | |||
| 3e2f62daeb | |||
| c8b8a8dbb4 | |||
| 9e4c5f11c5 | |||
| b16e72c10e | |||
| 5cb9b0b30b | |||
| c925001a8a | |||
| e494334720 | |||
| 1554540431 | |||
| fef09690d2 | |||
| 3a975fa74f | |||
| 69c6353fb1 | |||
| 2b9bf3cd3f | |||
| d99e16314b | |||
| e4203e0f15 | |||
| 2a5366d9c3 | |||
| 9c9cf24ddc | |||
| ec0d8ad735 | |||
| fc9e9d13e6 | |||
| d0cb7e66f4 | |||
| bd66480ccc | |||
| b05f1f6bcf | |||
| 12ad9c9436 | |||
| c8ce7cdb37 | |||
| 56d4dd8b84 | |||
| 86fdc5aca9 | |||
| 87c5bc59dc | |||
| 21b30b21c2 | |||
| f94f1c0c4e | |||
| a6a9883451 | |||
| 03116a97d1 | |||
| 2abc78ab6d | |||
| 6e54b52376 | |||
| 7cef2ea361 | |||
| 5b5ee0e8fd | |||
| 7bcc4f00b1 | |||
| 34ec44e3d4 | |||
| f607e21c13 | |||
| 5940e9fc6a | |||
| 98cfd76232 | |||
| 09e68a614f | |||
| 88290caadc | |||
| f442dce9a4 | |||
| 4f03f39e9a | |||
| 38b667799c | |||
| a5f74d6abc | |||
| d0e09301a1 | |||
| 177915ea6b |
+1
-1
Submodule .claude/commands updated: 92d8689b1e...b8db845dee
@@ -55,6 +55,10 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
# pins are reviewed in a PR instead of drifting silently or being bumped by hand.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns the request headers that authorize an Azure Storage REST call with Microsoft Entra.
|
||||
|
||||
.DESCRIPTION
|
||||
One place for the storage OAuth contract shared by every publisher in this repo -- the
|
||||
agent ZIP and MSI, the customer bootstrap scripts, the version manifests and the build
|
||||
counters. Resource id, the az invocation, the service version and the failure message
|
||||
live here so they cannot drift between call sites.
|
||||
|
||||
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
|
||||
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
|
||||
operator's own account for the local build. No secret is read, stored or passed around.
|
||||
|
||||
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
|
||||
below 2017-11-09, and Table requests without a SAS must carry the header at all. 2021-08-06
|
||||
is old enough to be deployed in every region and new enough for everything we send.
|
||||
|
||||
Callers merge the result into their own headers -- `$auth + @{ ... }` yields a new
|
||||
hashtable per request, so nothing is shared across uploads:
|
||||
|
||||
$auth = & "$PSScriptRoot/Get-StorageAuthHeaders.ps1"
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($auth + @{ 'x-ms-blob-type' = 'BlockBlob' }) -Body $bytes
|
||||
|
||||
Acquire once per step and reuse: every call shells out to az (~1s) and the token is valid
|
||||
for the lifetime of a job.
|
||||
|
||||
.OUTPUTS
|
||||
[hashtable] Authorization + x-ms-version.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The data-plane resource, not management.azure.com -- a management token is accepted by
|
||||
# neither the blob nor the table endpoint.
|
||||
$token = az account get-access-token --resource https://storage.azure.com --query accessToken -o tsv 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw ('Could not acquire an Azure Storage token. In CI this means the azure/login step ' +
|
||||
'is missing or its federated credential does not cover this ref; locally, run ' +
|
||||
'`az login`. A token alone is not enough: the principal also needs Storage Blob ' +
|
||||
'Data Contributor on the container it writes, and Storage Table Data Contributor ' +
|
||||
'on the table -- a missing role shows up later as HTTP 403 AuthorizationPermissionMismatch.')
|
||||
}
|
||||
|
||||
return @{
|
||||
'Authorization' = "Bearer $token"
|
||||
'x-ms-version' = '2021-08-06'
|
||||
}
|
||||
@@ -12,52 +12,78 @@
|
||||
lands a consistent set (ZIP + manifest + scripts).
|
||||
|
||||
Both callers run the same code. The scripts on the download alias are the live
|
||||
copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every
|
||||
enrolling device, so "current" is a correctness property, not cosmetics.
|
||||
copy the stage-1 loader downloads on every enrolling device, whether it came from
|
||||
Intune or from the bootstrap MSI, so "current" is a correctness property, not
|
||||
cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the bootstrap source.
|
||||
2. Render the -Dev variant by literal substitution of the two URL/manifest
|
||||
1. Parse $ScriptVersion from the loader and the bootstrap source.
|
||||
2. Render the -Dev variants by literal substitution of the URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
3. Version-bump guard: if the published bootstrap differs from the source
|
||||
but carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
3. Version-bump guard: if a published script differs from its source but
|
||||
carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
|
||||
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
|
||||
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
|
||||
and nothing may touch them afterwards or the signature breaks.
|
||||
6. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
5. Reconcile the two version oracles: version.json.bootstrapVersion
|
||||
(read-modify-write under If-Match, so a concurrent agent release cannot
|
||||
lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
6. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
7. Reconcile the version oracles: version.json.bootstrapVersion and
|
||||
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
release cannot lose its agent fields) and
|
||||
AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
8. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS for the eu storage account with write permission. Leading '?' tolerated.
|
||||
Comparisons against a published script always strip its signature block first
|
||||
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
|
||||
every run; without stripping, the bump guard and the drift check would report a
|
||||
change for every unchanged script.
|
||||
|
||||
.PARAMETER LegacySasToken
|
||||
Container SAS for the legacy account. Optional -- a mirror failure warns, never fails.
|
||||
|
||||
.PARAMETER TableSasToken
|
||||
Table SAS for AdminConfiguration. Optional -- omitted or 'XXX' skips the oracle update.
|
||||
The legacy account lives in a different tenant, so the ambient Entra identity cannot
|
||||
reach it; this is the only credential the publisher still takes.
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
Repository root. Defaults to the parent of .github/scripts.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Run every check and render, upload nothing.
|
||||
Run every check and render, upload nothing. A dry run signs when a token is supplied
|
||||
and skips signing when it is not: the PR gate runs this without any Azure login.
|
||||
|
||||
.PARAMETER SigningToken
|
||||
Azure Key Vault access token for AzureSignTool. MANDATORY for a real publish -- an
|
||||
unsigned customer script is not something this repo publishes. Together with
|
||||
-KeyVaultUrl and -CertificateName.
|
||||
|
||||
.PARAMETER ExpectedPublisher
|
||||
Optional -like pattern the signer subject must match after signing. Pins the publisher
|
||||
instead of accepting any trusted signature; the loader on the device pins the same way.
|
||||
|
||||
.PARAMETER SignedOutputDir
|
||||
Optional directory that receives a copy of every published file exactly as uploaded.
|
||||
Used by CI to offer the signed scripts as a build artifact for local inspection.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS
|
||||
./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS -SigningToken $env:AKV_TOKEN `
|
||||
-KeyVaultUrl $env:KV_URL -CertificateName $env:CERT
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SasToken,
|
||||
[string]$LegacySasToken,
|
||||
[string]$TableSasToken,
|
||||
[string]$RepoRoot,
|
||||
[string]$SigningToken,
|
||||
[string]$KeyVaultUrl,
|
||||
[string]$CertificateName,
|
||||
[string]$TimestampUrl = 'http://timestamp.acs.microsoft.com',
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$SignedOutputDir,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
@@ -66,9 +92,6 @@ $ErrorActionPreference = 'Stop'
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
if (-not $DryRun -and [string]::IsNullOrWhiteSpace($SasToken)) {
|
||||
throw 'SasToken is required unless -DryRun is used.'
|
||||
}
|
||||
|
||||
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
|
||||
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
|
||||
@@ -78,8 +101,23 @@ $TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminCo
|
||||
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
|
||||
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
|
||||
$DevBlob = 'Install-AutopilotMonitor-Dev.ps1'
|
||||
# Stage 1. Assigned in Intune once, and embedded in the bootstrap MSI; it downloads the
|
||||
# bootstrap above on every device and verifies its publisher before running it.
|
||||
$LoaderSource = Join-Path $RepoRoot 'scripts/Bootstrap/Start-AutopilotMonitor.ps1'
|
||||
$LoaderBlob = 'Start-AutopilotMonitor.ps1'
|
||||
$LoaderDevBlob = 'Start-AutopilotMonitor-Dev.ps1'
|
||||
$ScriptContentType = 'text/plain; charset=utf-8'
|
||||
|
||||
$SigningEnabled = -not [string]::IsNullOrWhiteSpace($SigningToken)
|
||||
if (-not $DryRun -and -not $SigningEnabled) {
|
||||
throw ('Publishing requires -SigningToken (plus -KeyVaultUrl and -CertificateName): the ' +
|
||||
'loader on every device verifies the publisher of what it downloads, so an unsigned ' +
|
||||
'script would be rejected in the field. Only -DryRun may run without signing.')
|
||||
}
|
||||
if ($SigningEnabled -and ([string]::IsNullOrWhiteSpace($KeyVaultUrl) -or [string]::IsNullOrWhiteSpace($CertificateName))) {
|
||||
throw '-SigningToken needs -KeyVaultUrl and -CertificateName.'
|
||||
}
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
@@ -125,6 +163,132 @@ function Get-PublishBytes {
|
||||
return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised)
|
||||
}
|
||||
|
||||
# A PowerShell signature is appended as a comment block at the end of the file, so the
|
||||
# published bytes are "source + signature". Everything that compares a published script to
|
||||
# its source has to cut that block off first -- the signature carries a timestamp and is
|
||||
# therefore different on every publish, while the script above it is unchanged.
|
||||
function Remove-SignatureBlock {
|
||||
param([string]$Text)
|
||||
$marker = '# SIG # Begin signature block'
|
||||
$index = $Text.IndexOf($marker)
|
||||
if ($index -lt 0) { return $Text }
|
||||
return $Text.Substring(0, $index)
|
||||
}
|
||||
|
||||
# Comparison form: no signature, LF, no trailing blank lines. Used for "is the published
|
||||
# copy still this source" -- never for what gets uploaded.
|
||||
function Get-ComparableText {
|
||||
param([string]$Text)
|
||||
return (Remove-SignatureBlock $Text).Replace("`r`n", "`n").TrimEnd("`n")
|
||||
}
|
||||
|
||||
# Signs the publish set in place: each item's bytes are written to a work directory, signed
|
||||
# there, verified, and read back as the bytes that will be uploaded. Signing has to be the
|
||||
# last transformation -- the signature covers exactly these bytes, so nothing may normalise
|
||||
# or re-encode them afterwards.
|
||||
function Set-PublishSetSignature {
|
||||
param([object[]]$Items, [string]$WorkDir)
|
||||
|
||||
if (-not (Get-Command azuresigntool -ErrorAction SilentlyContinue)) {
|
||||
throw "azuresigntool is not on PATH. Install it with: dotnet tool install --global AzureSignTool --version 7.0.1"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
|
||||
$paths = @()
|
||||
foreach ($item in $Items) {
|
||||
$path = Join-Path $WorkDir $item.BlobName
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]$item.Bytes)
|
||||
$paths += $path
|
||||
}
|
||||
|
||||
Write-Host "Signing $($paths.Count) script(s) with certificate '$CertificateName'..."
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url $KeyVaultUrl `
|
||||
--azure-key-vault-accesstoken $SigningToken `
|
||||
--azure-key-vault-certificate $CertificateName `
|
||||
--timestamp-rfc3161 $TimestampUrl `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$paths
|
||||
|
||||
# Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing
|
||||
# failure would publish unsigned scripts that every loader in the field then rejects.
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- nothing was uploaded."
|
||||
}
|
||||
|
||||
foreach ($item in $Items) {
|
||||
$path = Join-Path $WorkDir $item.BlobName
|
||||
$sig = Get-AuthenticodeSignature -FilePath $path
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
throw "Signature on $($item.BlobName) is '$($sig.Status)', expected 'Valid'."
|
||||
}
|
||||
# Without a timestamp the signature dies with the certificate, and these scripts sit
|
||||
# on customer devices far longer than a certificate lifetime.
|
||||
if (-not $sig.TimeStamperCertificate) {
|
||||
throw "Signature on $($item.BlobName) carries no timestamp."
|
||||
}
|
||||
if ($ExpectedPublisher -and $sig.SignerCertificate.Subject -notlike $ExpectedPublisher) {
|
||||
throw "$($item.BlobName) was signed by '$($sig.SignerCertificate.Subject)', expected '$ExpectedPublisher'."
|
||||
}
|
||||
# Plain assignment, NO comma operator: the ',' elsewhere in this file exists because
|
||||
# PowerShell unrolls an array RETURNED from a function, which is not what a property
|
||||
# assignment does -- here it would wrap the byte[] in an Object[] of length 1 and the
|
||||
# upload would fail converting it back.
|
||||
$signedBytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($signedBytes.Length -le $item.Bytes.Length) {
|
||||
throw ("Signed $($item.BlobName) is $($signedBytes.Length) bytes but the unsigned form " +
|
||||
"was $($item.Bytes.Length) -- a signature only ever adds bytes, so this is a " +
|
||||
'read-back bug, not a signing result.')
|
||||
}
|
||||
$item.Bytes = $signedBytes
|
||||
Write-Host " signed $($item.BlobName) ($($signedBytes.Length) bytes, signer $($sig.SignerCertificate.Subject.Split(',')[0]))"
|
||||
}
|
||||
}
|
||||
|
||||
# Aborts when a published script changed behaviour without a version bump. Consumers act on
|
||||
# the version (docs badge, version.json, the portal's outdated-script hint), so an unchanged
|
||||
# version has to mean unchanged behaviour.
|
||||
function Assert-VersionBump {
|
||||
param(
|
||||
[string]$BlobName,
|
||||
[string]$SourceContent,
|
||||
[string]$SourceOrigin,
|
||||
[string]$SourceVersion
|
||||
)
|
||||
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BlobName"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host "No published $BlobName yet -- version-bump guard skipped (first publish)"
|
||||
return
|
||||
}
|
||||
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
if ((Get-ComparableText $publishedText) -eq (Get-ComparableText $SourceContent)) {
|
||||
Write-Host "Published $BlobName is already this source (signature block aside)"
|
||||
return
|
||||
}
|
||||
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BlobName" -Optional
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BlobName is not a readable script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $SourceVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $SourceVersion ($BlobName)"
|
||||
} elseif ((Get-CodeFingerprint -Text (Remove-SignatureBlock $publishedText) -Origin "$AliasUrl/$BlobName") -eq
|
||||
(Get-CodeFingerprint -Text $SourceContent -Origin $SourceOrigin)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BlobName changed in comments or formatting only; publishing under the unchanged version $SourceVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BlobName changed behaviour but ScriptVersion is still $SourceVersion. " +
|
||||
"Bump it in $SourceOrigin -- the docs badge, version.json and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
@@ -152,12 +316,16 @@ function Get-CodeFingerprint {
|
||||
return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 1. source
|
||||
# ------------------------------------------------------------------ 1. sources
|
||||
$sourceContent = Get-Content $BootstrapSource -Raw
|
||||
$sourceBytes = Get-PublishBytes $sourceContent
|
||||
$scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource
|
||||
Write-Host "Bootstrap script version: $scriptVersion"
|
||||
|
||||
$loaderContent = Get-Content $LoaderSource -Raw
|
||||
$loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $LoaderSource
|
||||
Write-Host "Loader script version: $loaderVersion"
|
||||
|
||||
# ------------------------------------------------------------------ 2. dev render
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
@@ -170,33 +338,21 @@ if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($
|
||||
$devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral)
|
||||
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guard
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)'
|
||||
} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) {
|
||||
Write-Host 'Published bootstrap is already byte-identical to the source'
|
||||
} else {
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional
|
||||
# The dev loader must fetch the dev bootstrap, or a dev device would run the stable chain.
|
||||
$stableLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
||||
$devLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1"'
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $scriptVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $scriptVersion"
|
||||
} elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq
|
||||
(Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " +
|
||||
"Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) {
|
||||
throw "Loader dev-render: anchor literal missing in $LoaderSource"
|
||||
}
|
||||
$devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiteral)
|
||||
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
|
||||
|
||||
# ------------------------------------------------------------------ 4. upload
|
||||
# ------------------------------------------------------------------ 3. bump guards
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
|
||||
# ------------------------------------------------------------------ 4. publish set
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
$extraSources = @(
|
||||
@{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' }
|
||||
@@ -206,20 +362,38 @@ $extraSources = @(
|
||||
# Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and
|
||||
# an unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
# Text is the unsigned publish form and stays the reference for every comparison against a
|
||||
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Bytes = (Get-PublishBytes $devContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
throw "Publish source missing: $extraPath"
|
||||
}
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) })
|
||||
$extraText = Get-Content $extraPath -Raw
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Text = $extraText; Bytes = (Get-PublishBytes $extraText) })
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. sign
|
||||
# After this point the bytes are final. Normalising, re-encoding or rewriting a line ending
|
||||
# would invalidate the signature, and the device-side loader would refuse the script.
|
||||
if ($SigningEnabled) {
|
||||
Set-PublishSetSignature -Items $publishSet -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-$PID")
|
||||
} else {
|
||||
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
$writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' }
|
||||
|
||||
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
|
||||
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
|
||||
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
|
||||
|
||||
# ------------------------------------------------------------------ 6. upload
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
@@ -227,11 +401,21 @@ foreach ($item in $publishSet) {
|
||||
[byte[]]$bytes = $item.Bytes
|
||||
$item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force
|
||||
|
||||
# Exactly the bytes that go on the wire, for the CI artifact. Written in a dry run too:
|
||||
# that is the run an operator uses to inspect a signed script before it is published.
|
||||
if ($SignedOutputDir) {
|
||||
New-Item -ItemType Directory -Force -Path $SignedOutputDir | Out-Null
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $SignedOutputDir $item.BlobName), $bytes)
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this source.
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this
|
||||
# source. Compared as text without the signature block -- signed bytes differ on
|
||||
# every run, so a byte comparison would report drift for every unchanged script.
|
||||
$served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
$state = if ($null -eq $served) { 'MISSING on the alias' }
|
||||
elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' }
|
||||
elseif ((Get-ComparableText ([System.Text.Encoding]::UTF8.GetString($served))) -eq
|
||||
(Get-ComparableText $item.Text)) { 'already current' }
|
||||
else { 'STALE on the alias' }
|
||||
Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state"
|
||||
continue
|
||||
@@ -241,8 +425,18 @@ foreach ($item in $publishSet) {
|
||||
# with Front Door. Route caching is disabled there, but a stale script paired with a
|
||||
# fresh manifest would fail the bootstrap SHA check -- this keeps that class of bug
|
||||
# impossible even if caching is ever re-enabled.
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
#
|
||||
# Content-Disposition: without it a text/plain blob opens as a web page in every
|
||||
# browser, and the documented "download the script" step silently becomes "copy this
|
||||
# text". The header makes the link a download everywhere, and command-line clients
|
||||
# (the loader, the MSI, curl) ignore it.
|
||||
$headers = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
'Content-Type' = $ScriptContentType
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'x-ms-blob-content-disposition' = ('attachment; filename="{0}"' -f $item.BlobName)
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)" -Method Put -Headers ($authHeaders + $headers) -Body $bytes | Out-Null
|
||||
|
||||
# Read straight back from the blob (authoritative, no CDN in the way) before touching the
|
||||
# mirror or the next file. The alias verification at the end would catch a bad body too,
|
||||
@@ -266,7 +460,7 @@ foreach ($item in $publishSet) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. version oracles
|
||||
# ------------------------------------------------------------------ 7. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
@@ -278,11 +472,15 @@ if (-not $DryRun) {
|
||||
} else { [string]$manifestResp.Content }
|
||||
$manifest = $manifestRaw | ConvertFrom-Json
|
||||
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion"
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion -and $manifest.loaderVersion -eq $loaderVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion and loaderVersion $loaderVersion"
|
||||
} else {
|
||||
$etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
$manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force
|
||||
# Additive: no consumer reads loaderVersion yet. It exists so support can tell
|
||||
# which stage-1 file a device was assigned -- stage 2 is always current by
|
||||
# construction, so its version says nothing about the customer's Intune copy.
|
||||
$manifest | Add-Member -NotePropertyName 'loaderVersion' -NotePropertyValue $loaderVersion -Force
|
||||
$body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress))
|
||||
$manifestHeaders = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
@@ -290,8 +488,8 @@ if (-not $DryRun) {
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'If-Match' = $etag
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json?$writeSas" -Method Put -Headers $manifestHeaders -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)"
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json" -Method Put -Headers ($authHeaders + $manifestHeaders) -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion, loaderVersion -> $loaderVersion (agent fields untouched)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
@@ -310,11 +508,8 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
$tableSas = if ($TableSasToken) { $TableSasToken.TrimStart('?') } else { '' }
|
||||
if ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSas) -or $tableSas -eq 'XXX') {
|
||||
Write-Host 'SKIPPED: AdminConfiguration update -- set AZURE_TABLE_ADMIN_SAS_TOKEN to enable the portal version oracle'
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
'Content-Type' = 'application/json'
|
||||
@@ -322,11 +517,11 @@ if ($DryRun) {
|
||||
'If-Match' = '*'
|
||||
}
|
||||
$tableBody = @{ LatestBootstrapV2ScriptVersion = $scriptVersion } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$TableUrl`?$tableSas" -Method Merge -Headers $tableHeaders -Body $tableBody | Out-Null
|
||||
Invoke-RestMethod -Uri $TableUrl -Method Merge -Headers ($authHeaders + $tableHeaders) -Body $tableBody | Out-Null
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. verify via alias
|
||||
# ------------------------------------------------------------------ 8. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
@@ -358,7 +553,8 @@ if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
'',
|
||||
"Bootstrap script version: **$scriptVersion**",
|
||||
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
|
||||
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
|
||||
''
|
||||
) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" })
|
||||
($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
A failed build burns a number, which is harmless -- uniqueness matters,
|
||||
density does not.
|
||||
|
||||
The blob is public-read; only the write needs the container SAS.
|
||||
The blob is public-read; only the write needs authorization, and that comes from the
|
||||
ambient Entra identity (see Get-StorageAuthHeaders.ps1) -- no secret is passed in.
|
||||
|
||||
Why a counter and not the published version manifest: manifests are written
|
||||
on publish, not on build (the agent writes version.json only for stable
|
||||
@@ -20,10 +21,7 @@
|
||||
overwrite each other's versioned artifact.
|
||||
|
||||
.PARAMETER CounterUrl
|
||||
Full URL of the counter blob, without SAS.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS with create+write permission. A leading '?' is tolerated.
|
||||
Full URL of the counter blob.
|
||||
|
||||
.PARAMETER Override
|
||||
Explicit build number instead of counter+1. If it is not greater than the
|
||||
@@ -37,20 +35,19 @@
|
||||
[int] the reserved build number. Progress goes to the host, not the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
$n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
|
||||
$n = ./Request-BuildNumber.ps1 -CounterUrl $url
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CounterUrl,
|
||||
[Parameter(Mandatory = $true)][string]$SasToken,
|
||||
[int]$Override = 0,
|
||||
[int]$MaxAttempts = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
|
||||
$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
|
||||
# The write is Entra-authorized; the read below stays anonymous (public container).
|
||||
$authHeaders = & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1')
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
@@ -85,8 +82,8 @@ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
$target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
|
||||
|
||||
try {
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
$headers = $authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
|
||||
Invoke-RestMethod -Uri $CounterUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
|
||||
Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
|
||||
return $target
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Action Pin Gate
|
||||
|
||||
# Every third-party action must be referenced by a full commit SHA, never by a tag.
|
||||
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
|
||||
# and that code runs inside jobs that hold the code-signing identity, the storage
|
||||
# credentials and a token that can push to main. A SHA is the only reference an
|
||||
# upstream owner cannot change under us.
|
||||
#
|
||||
# Lives in its own workflow rather than in ci.yml: CI deliberately ignores changes to
|
||||
# the other workflow files, so a guard placed there would stay silent for exactly the
|
||||
# commits it needs to inspect.
|
||||
#
|
||||
# Like CI, this reports rather than blocks: main is deliberately unprotected, so a red
|
||||
# run is a signal to act on, not a merge barrier. Making it binding means adding
|
||||
# 'Verify action pins' as a required status check -- a branch-protection decision, not
|
||||
# something this file can assert.
|
||||
#
|
||||
# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped
|
||||
# PR that rewrites both the SHA and the trailing version comment. The comment is
|
||||
# therefore required too: it is what makes a pinned line reviewable.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-pins:
|
||||
name: Verify action pins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Every third-party action is pinned to a SHA
|
||||
run: |
|
||||
set -uo pipefail
|
||||
failed=0
|
||||
while IFS=: read -r file line ref; do
|
||||
# Reusable workflows from this repository move with the commit that calls
|
||||
# them, so there is nothing to pin.
|
||||
case "$ref" in ./*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vN' so the line stays reviewable."
|
||||
else
|
||||
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
|
||||
fi
|
||||
failed=1
|
||||
# -H is load-bearing: with a single matching file grep omits the name, the
|
||||
# fields shift by one and every diagnostic points at nothing.
|
||||
done < <(grep -nHE '^[[:space:]]*(-[[:space:]]+)?uses:' .github/workflows/*.yml \
|
||||
| sed -E 's/^([^:]+):([0-9]+):[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*/\1:\2:/')
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::Unpinned action references found -- fix them before this reaches main."
|
||||
exit 1
|
||||
fi
|
||||
echo "All action references are pinned to commit SHAs."
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
|
||||
@@ -57,6 +57,13 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -65,14 +72,66 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
# Every released build is signed; a missing variable stops the run before a build
|
||||
# number is burnt.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "Repository variable $name is not set. Every released build must be Authenticode-signed."
|
||||
}
|
||||
}
|
||||
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
|
||||
|
||||
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account, so the storage login below has to be the last one. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration)
|
||||
# is authorized by this federated identity -- no SAS secret. One login covers the whole
|
||||
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
|
||||
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
|
||||
# lives in the pre-migration tenant, where this principal has no identity.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# Build numbers come from the shared counter blob -- one counter and one
|
||||
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
|
||||
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
|
||||
@@ -85,7 +144,6 @@ jobs:
|
||||
# build_number could otherwise break out of the quotes and run arbitrary code.
|
||||
env:
|
||||
BUILD_NUMBER_OVERRIDE: ${{ inputs.build_number }}
|
||||
AGENT_SAS_TOKEN: ${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}
|
||||
run: |
|
||||
$override = 0
|
||||
if ($env:BUILD_NUMBER_OVERRIDE -ne '') {
|
||||
@@ -96,7 +154,7 @@ jobs:
|
||||
}
|
||||
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken $env:AGENT_SAS_TOKEN -Override $override
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -129,6 +187,47 @@ jobs:
|
||||
- name: Run Agent unit tests
|
||||
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
|
||||
|
||||
# Authenticode over everything we author; third-party assemblies keep their own signature.
|
||||
#
|
||||
# THE POSITION IS LOAD-BEARING: signing rewrites the PE file, so it must run before the
|
||||
# ZIP and every SHA-256 in the next step -- those hashes are the bootstrap's integrity
|
||||
# oracle, and hashes taken before signing match no downloaded file.
|
||||
#
|
||||
# $LASTEXITCODE is checked explicitly: PowerShell does not throw on a native non-zero exit.
|
||||
- name: Sign agent binaries (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$targets = @()
|
||||
$targets += Get-ChildItem -Path "$env:AGENT_OUTPUT/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
|
||||
$targets += Get-ChildItem -Path "$env:DIALOG_OUTPUT/AutopilotMonitor.SummaryDialog.exe" -File
|
||||
|
||||
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.Agent.exe')) {
|
||||
throw "AutopilotMonitor.Agent.exe not found in $env:AGENT_OUTPUT -- nothing to sign."
|
||||
}
|
||||
if (-not ($targets | Where-Object Name -eq 'AutopilotMonitor.SummaryDialog.exe')) {
|
||||
throw "AutopilotMonitor.SummaryDialog.exe not found in $env:DIALOG_OUTPUT -- nothing to sign."
|
||||
}
|
||||
|
||||
$targets | ForEach-Object { Write-Host "Signing $($_.Name)" }
|
||||
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description "Autopilot Monitor Agent" `
|
||||
--description-url "https://www.autopilotmonitor.com" `
|
||||
($targets | ForEach-Object { $_.FullName })
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
@@ -219,12 +318,57 @@ jobs:
|
||||
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
|
||||
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Verified against the STAGING copy -- the exact bytes that ship. The invariant is "every
|
||||
# AutopilotMonitor PE in the ZIP is signed", so a project added later cannot ride along
|
||||
# unsigned. A missing timestamp fails too: without it the signature dies with the cert.
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$files = Get-ChildItem -Path "staging/agent/*" -Include 'AutopilotMonitor.*.exe', 'AutopilotMonitor.*.dll' -File
|
||||
if ($files.Count -lt 2) {
|
||||
throw "Expected signed binaries in staging/agent, found $($files.Count)."
|
||||
}
|
||||
|
||||
$failed = $false
|
||||
$thumbprints = @{}
|
||||
foreach ($file in $files) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $file.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error file=$($file.Name)::Signature status is '$($sig.Status)' -- expected 'Valid'."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
if (-not $sig.TimeStamperCertificate) {
|
||||
Write-Host "::error file=$($file.Name)::Signature carries no RFC-3161 timestamp -- it would expire with the certificate."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and
|
||||
$sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
Write-Host "::error file=$($file.Name)::Signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
$thumbprints[$sig.SignerCertificate.Thumbprint] = $true
|
||||
Write-Host "$($file.Name): Valid, timestamped $($sig.TimeStamperCertificate.Subject.Split(',')[0]), signer $($sig.SignerCertificate.Subject)"
|
||||
}
|
||||
|
||||
if ($thumbprints.Count -gt 1) {
|
||||
Write-Host "::error::Staged binaries carry $($thumbprints.Count) different signing certificates -- expected exactly one."
|
||||
$failed = $true
|
||||
}
|
||||
if ($failed) {
|
||||
throw "Signature verification failed. Nothing was published."
|
||||
}
|
||||
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
# Legacy mirror (transition until customers moved to the download.-alias):
|
||||
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
|
||||
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
|
||||
@@ -234,7 +378,6 @@ jobs:
|
||||
|
||||
function Send-Blob {
|
||||
param([string]$Local, [string]$BlobName, [string]$ContentType)
|
||||
$url = "$containerUrl/$BlobName`?$sasToken"
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
|
||||
# overwritten each build), and the download alias fronts them with Front Door.
|
||||
@@ -248,7 +391,7 @@ jobs:
|
||||
$md5.Dispose()
|
||||
}
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1MB, 2)) MB)..."
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers $headers -Body $bytes
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
if (-not [string]::IsNullOrEmpty($legacySasToken)) {
|
||||
try {
|
||||
@@ -274,56 +417,66 @@ jobs:
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
# Signed with the same identity: the device-side loader verifies the publisher
|
||||
# of what it downloads, and the publisher refuses to upload unsigned scripts.
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}'
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
|
||||
# must never point at a build that is not actually in the stable namespace.
|
||||
# LatestBootstrapV2ScriptVersion is NOT set here: it belongs to the script publisher
|
||||
# above, which also owns it on script-only pushes. One writer per field.
|
||||
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
|
||||
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
|
||||
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSasToken) -or $tableSasToken -eq "XXX") {
|
||||
Write-Host "SKIPPED: Table SAS token not configured -- set AZURE_TABLE_ADMIN_SAS_TOKEN secret to enable backend hash-oracle"
|
||||
} else {
|
||||
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
|
||||
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
|
||||
$agentVersion = "${{ steps.version.outputs.version }}"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
$tableBody = @{
|
||||
LatestAgentV2Version = $agentVersion
|
||||
LatestAgentV2Sha256 = $sha256
|
||||
LatestAgentV2ExeSha256 = $exeSha256
|
||||
}
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers ($authHeaders + @{
|
||||
"Content-Type" = "application/json"
|
||||
"Accept" = "application/json;odata=nometadata"
|
||||
"If-Match" = "*"
|
||||
} -Body ($tableBody | ConvertTo-Json)
|
||||
}) -Body ($tableBody | ConvertTo-Json)
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
|
||||
}
|
||||
|
||||
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
|
||||
# commit, workflow file) in a public transparency log. The digest covers the identical
|
||||
# Sigstore keyless provenance: binds the artifact digests to this workflow run (repo, ref,
|
||||
# commit, workflow file) in a public transparency log. The digests cover the identical
|
||||
# bytes uploaded to blob (versioned AND stable), so one attestation covers all copies.
|
||||
# Verify: gh attestation verify <zip> --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (ZIP)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
# Complements Authenticode rather than replacing it: the signature says who published a
|
||||
# binary, the attestation which build produced these exact bytes. Both EXEs are subjects
|
||||
# of their own so an extracted binary can be verified without the ZIP.
|
||||
# Verify: gh attestation verify <file> --repo okieselbach/AutopilotMonitor
|
||||
# Skipped on runs that publish nothing -- no log entry for bytes nobody receives.
|
||||
- name: Attest build provenance
|
||||
if: inputs.upload_blob || inputs.create_release
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
subject-path: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
staging/agent/AutopilotMonitor.Agent.exe
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
@@ -348,16 +501,6 @@ jobs:
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
RELEASE_NOTES: ${{ inputs.release_notes }}
|
||||
RELEASE_BODY_TAIL: |
|
||||
### Installation
|
||||
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
|
||||
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
|
||||
3. Run `AutopilotMonitor.Agent.exe --install` to register the Scheduled Task
|
||||
|
||||
### Included Components
|
||||
- **AutopilotMonitor.Agent.exe** - Monitoring agent (V2 release line)
|
||||
- **AutopilotMonitor.SummaryDialog.exe** - Enrollment summary dialog (WPF)
|
||||
- **version-v2.json** - Per-line version manifest
|
||||
|
||||
### Version Info
|
||||
- **Version:** ${{ steps.version.outputs.version }}
|
||||
- **Build Number:** ${{ steps.version.outputs.build_number }}
|
||||
@@ -366,7 +509,7 @@ jobs:
|
||||
- **Published as stable:** ${{ inputs.publish_as_stable }}
|
||||
|
||||
### Provenance
|
||||
This ZIP carries a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
@@ -392,7 +535,7 @@ jobs:
|
||||
$compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n"
|
||||
}
|
||||
|
||||
$body = "## $env:RELEASE_NAME`n`n### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
|
||||
$body = "### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
|
||||
Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline
|
||||
|
||||
|
||||
+126
-16
@@ -1,11 +1,11 @@
|
||||
name: Build & Publish Bootstrap MSI
|
||||
|
||||
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap
|
||||
# logic (only the runner script that downloads the CURRENT server-hosted
|
||||
# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script
|
||||
# or the WiX package changes. Version lives in Package.wxs — bump it there before
|
||||
# dispatching so Intune LOB update detection sees a new ProductVersion.
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI is packaging only: it
|
||||
# carries the SAME stage-1 loader that is assigned directly in Intune, so it is rebuilt
|
||||
# rarely — only when that loader or the WiX package changes. Version lives in Package.wxs
|
||||
# — bump it there before dispatching so Intune LOB update detection sees a new
|
||||
# ProductVersion.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -23,8 +23,15 @@ permissions:
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
|
||||
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -33,10 +40,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
# The runner executes as SYSTEM on every enrolling device — a parse error or
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -62,8 +71,77 @@ jobs:
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
|
||||
}
|
||||
}
|
||||
|
||||
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account, so the storage login below has to be the last one. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# The blob upload below is authorized by this federated identity, not by a SAS.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
|
||||
# install, so a compromised or simply broken point release must not flow into a build
|
||||
# unnoticed. Bumping is a reviewed commit.
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version "5.*"
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards
|
||||
# would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need
|
||||
# the embedded loader to carry a signature of its own.
|
||||
- name: Sign bootstrap loader (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap runner' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$env:RUNNER_SCRIPT
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
@@ -75,16 +153,46 @@ jobs:
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
Write-Host "SHA-256: $sha256"
|
||||
|
||||
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
|
||||
# timestamp fails the run: without it the signature dies with the certificate.
|
||||
- name: Sign MSI (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor Bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$env:STABLE_MSI
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
# The MSI and the loader it carries -- verified here, before anything is published.
|
||||
foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $file
|
||||
if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." }
|
||||
if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." }
|
||||
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
}
|
||||
|
||||
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
@@ -102,7 +210,7 @@ jobs:
|
||||
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
|
||||
$md5.Dispose()
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName`?$sasToken" -Method Put -Headers $headers -Body $bytes
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName" -Method Put -Headers ($authHeaders + $headers) -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
}
|
||||
|
||||
@@ -125,15 +233,17 @@ jobs:
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP. Verify:
|
||||
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
|
||||
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
if: inputs.upload_blob
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+49
-13
@@ -9,8 +9,8 @@ name: CI
|
||||
# red run reports rather than blocks. Read the result and follow up.
|
||||
#
|
||||
# Path filters: commits that only bump a private submodule gitlink (internal/, infra/,
|
||||
# .claude/commands) or touch prose cannot change anything the suites cover, so they are
|
||||
# skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
|
||||
# .claude/commands), touch prose or edit another workflow cannot change anything the
|
||||
# suites cover, so they are skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
|
||||
# path ('internal', no trailing slash), which 'internal/**' does NOT match — both forms
|
||||
# are listed. GitHub skips only when EVERY changed path matches; a commit that touches
|
||||
# docs AND code still runs.
|
||||
@@ -30,6 +30,24 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/codeql/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
|
||||
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
|
||||
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/action-pins.yml'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/codeql.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -45,6 +63,24 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/codeql/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
|
||||
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
|
||||
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/action-pins.yml'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/codeql.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -60,9 +96,9 @@ jobs:
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -91,9 +127,9 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -125,9 +161,9 @@ jobs:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -145,7 +181,7 @@ jobs:
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
@@ -159,9 +195,9 @@ jobs:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -180,9 +216,9 @@ jobs:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ name: CodeQL
|
||||
# A gitlink bump reports the bare path ('internal', no trailing slash), which
|
||||
# 'internal/**' does NOT match — both forms are listed. A commit that touches docs
|
||||
# AND code still runs: GitHub skips only when EVERY changed path matches the filter.
|
||||
# Same list as ci.yml; keep them in step.
|
||||
# Same list as ci.yml except that each file leaves itself out; keep them in step.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -23,6 +23,22 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -38,6 +54,22 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
|
||||
@@ -70,15 +102,15 @@ jobs:
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.37.9
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -89,6 +121,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4.37.9
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -24,14 +24,17 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Exact versions and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. ajv is pure JS and needs no install hooks.
|
||||
- name: Install ajv-cli
|
||||
run: npm install -g ajv-cli ajv-formats
|
||||
run: npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
|
||||
|
||||
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
|
||||
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -115,9 +115,9 @@ jobs:
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/backend.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Build and Deploy MCP Server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
# The published customer documentation lives in its own PUBLIC repo and is
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt"
|
||||
|
||||
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
|
||||
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
|
||||
@@ -144,9 +144,9 @@ jobs:
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/mcp.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -35,12 +35,38 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
# Only the version manifest at the very end needs Azure; logging in here rather than
|
||||
# next to that step keeps a credential problem from surfacing after a live deploy.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# The customer docs repo is the single source of the "What's new" panel: the prebuild
|
||||
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
|
||||
# public/whats-new.json and dates every bullet via git blame — hence the full history.
|
||||
# Public repo, no token. Editing the docs does NOT update the live panel until this
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: whats-new-docs
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve docs commit
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -51,7 +77,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
|
||||
- name: Build
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
env:
|
||||
@@ -68,9 +94,12 @@ jobs:
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
|
||||
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
@@ -85,12 +114,12 @@ jobs:
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
@@ -136,7 +165,8 @@ jobs:
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
|
||||
# docs revision the live What's new panel was generated from (as for the MCP corpus).
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -145,11 +175,12 @@ jobs:
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
@@ -34,6 +35,16 @@ concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
# Authenticode for the customer scripts. Same identity and same repository variables as
|
||||
# build-agent.yml -- the loader on every device pins this publisher, so the two must not
|
||||
# drift apart.
|
||||
env:
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -43,14 +54,81 @@ jobs:
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "Repository variable $name is not set. Published customer scripts must be signed."
|
||||
}
|
||||
}
|
||||
|
||||
# First, and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account and the storage login below has to be the one that stays. See D-223 --
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Authorizes the eu blob writes and the AdminConfiguration merge. The gates job above
|
||||
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
|
||||
# must never need Azure credentials.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Version pinned so a new or compromised NuGet release cannot flow into signing
|
||||
# unreviewed; roll-forward because the tool targets an older runtime than the SDK
|
||||
# preinstalled on the runner.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}' `
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT `
|
||||
-SignedOutputDir "$env:RUNNER_TEMP/signed-scripts" `
|
||||
-DryRun:$dryRun
|
||||
|
||||
# The exact bytes that were (or, on a dry run, would have been) uploaded. A dry-run
|
||||
# dispatch is therefore the way to inspect a signed script locally before it reaches
|
||||
# a single customer. Kept on failure too: a failed alias verification is precisely
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -145,6 +145,10 @@ internal/docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
# What's new payload: generated by scripts/whats-new/build-whats-new.js from the docs
|
||||
# checkout on every build and served at /whats-new.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/whats-new.json
|
||||
|
||||
|
||||
# Local IME installer scratch (MSI + extracted payload per version, for grepping
|
||||
# in binaries). Operator-only, never committed.
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
# <img src=".github/assets/logo.svg" height="28" alt="" /> Autopilot Monitor
|
||||
# <img src=".github/assets/logo.svg" height="28" alt="Autopilot Monitor logo" /> Autopilot Monitor
|
||||
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](LICENSE)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
|
||||
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
|
||||
Autopilot Monitor is an open-source, real-time monitoring and troubleshooting platform for **Windows Autopilot** and **Microsoft Intune** enrollment. It records enrollment events, application installations, policies, device state, and diagnostics while provisioning is still running, instead of collecting logs by hand after a failure. Administrators get live enrollment tracking, Enrollment Status Page (ESP) visibility, detailed session timelines, automated issue detection, on-demand diagnostics, fleet health analytics, and AI-assisted troubleshooting through a hosted Model Context Protocol (MCP) server.
|
||||
|
||||
The platform supports classic Windows Autopilot and modern Intune provisioning scenarios, including **Windows Autopilot Device Preparation**.
|
||||
|
||||
## Availability
|
||||
|
||||
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/SessionList.png" width="45%" />
|
||||
<img src=".github/assets/FleetHealth.png" width="45%" />
|
||||
<img src=".github/assets/SessionList.png" width="45%" alt="Autopilot Monitor dashboard showing live Windows Autopilot and Intune enrollment sessions" />
|
||||
<img src=".github/assets/FleetHealth.png" width="45%" alt="Autopilot Monitor fleet health dashboard with Windows Autopilot enrollment analytics and success metrics" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src=".github/assets/SessionDetails.png" width="45%" />
|
||||
<img src=".github/assets/SessionTimeline.png" width="45%" />
|
||||
<img src=".github/assets/SessionDetails.png" width="45%" alt="Autopilot Monitor session details showing device information, diagnostics, apps, policies, and enrollment status" />
|
||||
<img src=".github/assets/SessionTimeline.png" width="45%" alt="Windows Autopilot enrollment timeline showing ESP phases, application installs, policies, events, and errors" />
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
## Key Features
|
||||
|
||||
Autopilot Monitor provides real-time tracking, intelligent diagnostics, and automated troubleshooting for Windows Autopilot enrollment processes. It consists of:
|
||||
- **Real-time Windows Autopilot monitoring** — follow device provisioning while enrollment is still running
|
||||
- **Detailed enrollment timelines** — correlate Autopilot, ESP, Intune Management Extension, apps, policies, device state, and system events
|
||||
- **Enrollment Status Page visibility** — see which phase, application, or policy is delaying provisioning
|
||||
- **Automated issue detection** — define custom analysis rules that identify known failure patterns
|
||||
- **Targeted evidence collection** — gather registry values, event log entries, files, log-parser matches, and allow-listed commands when specific conditions are detected
|
||||
- **On-demand diagnostics** — request diagnostics packages from enrolled devices remotely
|
||||
- **Fleet health analytics** — success rates, deployment duration, device models, applications, errors, and recurring issues
|
||||
- **Session reporting** — inspect and share detailed enrollment information for individual devices
|
||||
- **Windows Autopilot Device Preparation support** — monitor Device Preparation deployments alongside classic Autopilot scenarios
|
||||
- **Centralized agent configuration** — control diagnostic behavior, automatic reboots, timezone adjustment, and other monitoring settings from the web dashboard
|
||||
- **AI-assisted troubleshooting** — query enrollment data through the built-in Model Context Protocol server
|
||||
|
||||
- **Bootstrap Script** — PowerShell script deployed via Intune that starts monitoring early in the enrollment process
|
||||
- **Monitoring Agent** — Lightweight .NET application that collects telemetry and evidence during enrollment
|
||||
## How it works
|
||||
|
||||
Autopilot Monitor consists of several components working together throughout Windows enrollment:
|
||||
|
||||
- **Bootstrap Script** — PowerShell script deployed through Microsoft Intune that starts monitoring early in the provisioning process
|
||||
- **Monitoring Agent** — lightweight .NET application that collects telemetry and evidence during enrollment
|
||||
- **Backend API** — Azure Functions-based ingestion and processing pipeline
|
||||
- **Web Dashboard** — Next.js application for real-time monitoring and fleet analytics
|
||||
- **MCP Server** — Model Context Protocol server that lets AI assistants query and troubleshoot enrollments in natural language
|
||||
- **Web Dashboard** — Next.js application for real-time monitoring, troubleshooting, and fleet analytics
|
||||
- **MCP Server** — Model Context Protocol server that lets AI assistants query Autopilot Monitor data in natural language
|
||||
|
||||
## Windows Autopilot and Intune Troubleshooting
|
||||
|
||||
Autopilot Monitor helps investigate common Windows provisioning and enrollment problems such as:
|
||||
|
||||
- Windows Autopilot deployments that fail or appear stuck
|
||||
- Enrollment Status Page (ESP) delays and unusually long deployment phases
|
||||
- Win32 application installation failures and slow application downloads
|
||||
- Intune Management Extension problems
|
||||
- Microsoft Entra join, Hybrid Join, and device registration issues
|
||||
- policies that block or delay provisioning
|
||||
- inconsistent behavior across hardware models and recurring failures affecting multiple devices
|
||||
|
||||
Because telemetry is collected during enrollment, many problems can be investigated without reproducing the issue or manually collecting logs from the affected device.
|
||||
|
||||
## AI Integration (MCP)
|
||||
|
||||
@@ -35,13 +65,19 @@ Autopilot Monitor ships a hosted **[Model Context Protocol](https://modelcontext
|
||||
|
||||
- *"Show me all failed enrollments from the last 24 hours and group them by failure reason."*
|
||||
- *"Why did session X fail, and which app install caused the delay?"*
|
||||
- *"Which devices had unusually long ESP application phases this week?"*
|
||||
- *"Which device models have the highest Autopilot failure rate?"*
|
||||
- *"Which devices in my fleet are affected by CVE-2024-30078?"*
|
||||
|
||||
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and no credentials are stored. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
|
||||
Server URL: `https://mcp.autopilotmonitor.com/mcp` — sign-in runs through your existing work account, access is scoped to your tenant exactly like in the portal, and the MCP server stores no credentials. Setup guide: **[AI Integration (MCP)](https://docs.autopilotmonitor.com/integrations/ai-integration-mcp)**.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)** — deployment, configuration, monitoring, diagnostics, troubleshooting, analysis and gather rules, integrations, and AI-assisted troubleshooting.
|
||||
|
||||
## Open Source
|
||||
|
||||
Autopilot Monitor is developed openly on GitHub. The source is published so that customers and security reviewers can see what runs on their devices and with their data: the repository contains the device agent, shared library, backend, web dashboard, and MCP server.
|
||||
|
||||
## Feedback
|
||||
|
||||
|
||||
+1
-1
Submodule infra updated: 2c2687b6ab...55a8eaf929
+1
-1
Submodule internal updated: 2316e7c0f3...3b7f771e65
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -93,7 +93,7 @@
|
||||
"steps": [
|
||||
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
|
||||
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-018",
|
||||
"title": "App Return Code Requested a Reboot During Enrollment",
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "in",
|
||||
"value": "SoftReboot,HardReboot",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "equals",
|
||||
"value": "HardReboot",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"condition": "exists",
|
||||
"weight": 25
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the app's return-code mapping",
|
||||
"steps": [
|
||||
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
|
||||
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
|
||||
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
|
||||
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Take the app out of the ESP critical path",
|
||||
"steps": [
|
||||
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
|
||||
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
|
||||
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/app-management/deployment/add-win32#step-2-program"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"return-code",
|
||||
"soft-reboot",
|
||||
"hard-reboot",
|
||||
"esp",
|
||||
"second-sign-in"
|
||||
]
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-004",
|
||||
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
|
||||
"evaluateOn": ["enrollment_end", "on_event:entra_user_affinity_pending"],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
"preconditions": [
|
||||
@@ -17,14 +17,14 @@
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
|
||||
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"signal": "user_affinity_pending",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
@@ -38,28 +38,27 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"signal": "no_user_token",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"eventType": "ime_user_token_acquired",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 2", "weight": 20 },
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
|
||||
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
|
||||
{ "signal": "entra_user_affinity_pending", "condition": "count >= 2", "weight": 20 },
|
||||
{ "signal": "entra_user_affinity_pending", "condition": "count >= 3", "weight": 10 }
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
|
||||
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
|
||||
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+115
-23
@@ -615,7 +615,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -703,7 +703,7 @@
|
||||
"steps": [
|
||||
"Open Event Viewer → Applications and Services Logs → Microsoft → Windows → AppXDeploymentServer → Operational on the device (or in the diagnostics archive if uploaded)",
|
||||
"Filter for Error-level events around the ESP failure time and confirm the package name and HRESULT match this finding",
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = invalid/corrupt package, 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
"Look up the HRESULT: 0x80073cf9 = generic install failure, 0x80073cf0 = package could not be opened (unsigned, publisher mismatch, or not found), 0x80073cff = sideloading/licensing policy block, 0x80073d02 = blocked because app is in use"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1034,6 +1034,103 @@
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-018",
|
||||
"title": "App Return Code Requested a Reboot During Enrollment",
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "in",
|
||||
"value": "SoftReboot,HardReboot",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"source": "event_data",
|
||||
"eventType": "app_install_completed",
|
||||
"dataField": "exitCodeClass",
|
||||
"operator": "equals",
|
||||
"value": "HardReboot",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hard_reboot_return_code",
|
||||
"condition": "exists",
|
||||
"weight": 25
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "App **{{appName}}** returned exit code **{{exitCode}}**, which its Intune return-code table maps to **{{exitCodeClass}}** - the installer asked for a restart before its work is complete.\n\n- **SoftReboot**: the Intune Management Extension reports the app as *pending reboot*. The Enrollment Status Page treats it as completed and moves on, and Intune only marks the app installed after the next restart re-evaluates the detection rule. Until then the app may be unusable or half-configured, and the user sees a restart notification.\n- **HardReboot**: the Intune Management Extension stops processing further apps and, in Device Setup, restarts the device right away (the restart grace period does not apply during the ESP) - the same mid-enrollment restart and second sign-in that ANALYZE-ESP-005 attributes to reboot-required policies. In Account Setup the extension does not restart the device itself and the app stays pending.\n\nA restart requested by an installer is normal for many MSI packages (exit codes 3010 and 1641), but during Autopilot it lengthens the ESP, can surprise the user with a restart, and hides the app's real state until after the reboot. The return-code class is the app's own configuration in Intune, not a device fault.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the app's return-code mapping",
|
||||
"steps": [
|
||||
"Open the app in the Intune admin center > Properties > Program and review the return codes table: exit code {{exitCode}} is currently mapped to {{exitCodeClass}}",
|
||||
"If the installer only recommends a restart (MSI 3010 ERROR_SUCCESS_REBOOT_REQUIRED, 1641 ERROR_SUCCESS_REBOOT_INITIATED), map the code to Soft reboot instead of Hard reboot so the next app can install before the restart",
|
||||
"If the app works without an immediate restart, suppress the installer's own reboot (for MSI: REBOOT=ReallySuppress on the install command) so it returns 0 and the restart happens on your schedule",
|
||||
"Keep Hard reboot only when the following apps genuinely cannot install before the restart - and then expect the mid-ESP restart in every enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Take the app out of the ESP critical path",
|
||||
"steps": [
|
||||
"If the app is not needed for first use, remove it from the ESP blocking-app list so the restart happens after the user reaches the desktop",
|
||||
"If it must stay blocking, order it last in Device Setup (dependencies or install order) so a hard reboot does not interrupt the remaining apps",
|
||||
"For Soft reboot apps, plan a restart at the end of enrollment or accept that the app finishes on the user's first restart"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Add a Win32 app - Program step (device restart behavior and return codes)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/app-management/deployment/add-win32#step-2-program"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"return-code",
|
||||
"soft-reboot",
|
||||
"hard-reboot",
|
||||
"esp",
|
||||
"second-sign-in"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
@@ -2940,16 +3037,16 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-004",
|
||||
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:hybrid_login_pending"
|
||||
"on_event:entra_user_affinity_pending"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
@@ -2958,14 +3055,14 @@
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
|
||||
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"signal": "user_affinity_pending",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
@@ -2979,40 +3076,35 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"signal": "no_user_token",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"eventType": "ime_user_token_acquired",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"signal": "entra_user_affinity_pending",
|
||||
"condition": "count >= 2",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"condition": "count >= 3",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "aad_placeholder_user_detected",
|
||||
"signal": "entra_user_affinity_pending",
|
||||
"condition": "count >= 3",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
|
||||
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
|
||||
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+52
-4
@@ -130,16 +130,16 @@
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"title": "Collect dsregcmd join status on pending user affinity",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": [
|
||||
@@ -149,6 +149,54 @@
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-007",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1098)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1098",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-008",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1097)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1097",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-ENROLL-001",
|
||||
"title": "MDM enrollment diagnostic events",
|
||||
|
||||
+11
@@ -339,6 +339,17 @@
|
||||
"description": "Captures the installer exit code (lpExitCode) for the current app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-CLASS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as (?<exitCodeClass>Success|SoftReboot|HardReboot|Retry|Failed)$",
|
||||
"action": "captureExitCodeClass",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Captures the admin-defined return-code class of the installer exit code for the current app ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs, enum AppReturnCodeType - literal identical from IME 1.50 through 1.105). Stored as exitCodeClass on the app and emitted on its terminal events; the last value wins, so a Retry loop that ends in Success reports Success. Anchored on both ends on purpose: the no-mapping warning ('... defined as <n>, fall into default now.') and an unparsable numeric type print a raw number and must not match. Companion of IME-EXITCODE (the number, logged just before) and IME-EXITCODE-MAPPED-FAILED (the Failed state transition), which still owns the error path.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"title": "Collect dsregcmd join status on pending user affinity",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": ["device", "entra", "join", "hybrid"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-007",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1098)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1098",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": ["device", "entra", "hybrid"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-008",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1097)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1097",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": ["device", "entra", "hybrid"]
|
||||
}
|
||||
+25
-1
@@ -94,7 +94,11 @@
|
||||
"C:\\Windows\\Panther",
|
||||
"C:\\Windows\\SetupDiag",
|
||||
"C:\\ProgramData\\Microsoft\\DiagnosticLogCSP",
|
||||
"C:\\Windows\\SoftwareDistribution\\ReportingEvents.log"
|
||||
"C:\\Windows\\SoftwareDistribution\\ReportingEvents.log",
|
||||
"C:\\ProgramData\\RealmJoin\\choco\\logs"
|
||||
],
|
||||
"userProfileFilePrefixes": [
|
||||
"AppData\\Local\\RealmJoin"
|
||||
],
|
||||
"wmiQueryPrefixes": [
|
||||
"SELECT * FROM Win32_OperatingSystem",
|
||||
@@ -282,5 +286,25 @@
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
],
|
||||
"imeLogDerivedEventTypes": [
|
||||
"app_install_started",
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
"do_telemetry",
|
||||
"script_started",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
"script_timeout_suspected",
|
||||
"esp_phase_changed",
|
||||
"ime_agent_version",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ime_process_exited",
|
||||
"ime_user_token_acquired"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE-CLASS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\] lpExitCode is defined as (?<exitCodeClass>Success|SoftReboot|HardReboot|Retry|Failed)$",
|
||||
"action": "captureExitCodeClass",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Captures the admin-defined return-code class of the installer exit code for the current app ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs, enum AppReturnCodeType - literal identical from IME 1.50 through 1.105). Stored as exitCodeClass on the app and emitted on its terminal events; the last value wins, so a Retry loop that ends in Success reports Success. Anchored on both ends on purpose: the no-mapping warning ('... defined as <n>, fall into default now.') and an unparsable numeric type print a raw number and must not match. Companion of IME-EXITCODE (the number, logged just before) and IME-EXITCODE-MAPPED-FAILED (the Failed state transition), which still owns the error path.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source of the signal.",
|
||||
"description": "Source of the signal. phase_duration and app_install_duration never derive a duration from a span with mixed timestamp provenance (exactly one IME-log endpoint with sourceOffsetOrigin=reader-zone-fallback, i.e. the writer timezone was unknown to the agent for one endpoint only): such a condition evaluates false with evidence timestampProvenance=reader-zone-fallback. Two fallback endpoints share the same error and stay measurable.",
|
||||
"enum": ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "app_install_duration", "event_correlation", "clock_skew"]
|
||||
},
|
||||
"skewMetric": {
|
||||
|
||||
@@ -114,6 +114,11 @@
|
||||
"description": "Emit behavior: \"always\" (default — emit on every collection) or \"on_change\" (poll on the trigger cadence but emit only when the collected result changes; the first in-scope result always emits).",
|
||||
"enum": ["always", "on_change"]
|
||||
},
|
||||
"enrichErrorCodes": {
|
||||
"type": "boolean",
|
||||
"description": "Opt-in: let the backend explain error-code fields in this rule's output (exitCode, errorCode, hresult, ...) with the Windows/MSI/Intune error-code catalog, attaching the same *Info siblings built-in events get. Default false because gather-rule codes usually come from third-party logs or commands (HP Image Assistant, Dell Command Update, custom scripts) with their own exit-code numbering, where the catalog meaning would be wrong. Enable only when the parsed source really uses Windows codes (e.g. an msiexec or IME log).",
|
||||
"default": false
|
||||
},
|
||||
"outputEventType": {
|
||||
"type": "string",
|
||||
"description": "EventType for the emitted event (e.g., \"gather_proxy_settings\")."
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"$schema": { "type": "string" },
|
||||
"registryPrefixes": { "$ref": "#/$defs/categorizedList" },
|
||||
"filePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"userProfileFilePrefixes": {
|
||||
"description": "Folders under the signed-in user's profile that file collectors and diagnostics paths may read, RELATIVE to the profile root (e.g. \"AppData\\\\Local\\\\Vendor\"). Resolved against %LOGGED_ON_USER_PROFILE% at match time. Entries outside AppData\\Local / AppData\\Roaming are dropped at load: the C:\\Users hard block never admits them, so this list can only ever narrow what the block's exception already permits.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/safeString" },
|
||||
{ "pattern": "^AppData\\\\(Local|Roaming)(\\\\|$)" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"wmiQueryPrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"eventLogChannels": { "$ref": "#/$defs/categorizedList" },
|
||||
"blockedEventLogChannels": { "$ref": "#/$defs/stringList" },
|
||||
@@ -16,6 +26,7 @@
|
||||
"blockedFilePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"blockedCommandPatterns": { "$ref": "#/$defs/stringList" },
|
||||
"blockedInterimTriggerEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"imeLogDerivedEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"maxCommandLength": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"captureAppTypeWinGet",
|
||||
"captureAppTypeMsi",
|
||||
"captureAttemptNumber",
|
||||
"captureDetectionResult"
|
||||
"captureDetectionResult",
|
||||
"captureExitCodeClass"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -142,6 +142,7 @@ if (fs.existsSync(guardrailsPath)) {
|
||||
assertStringList(guardrails[key], key);
|
||||
}
|
||||
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
|
||||
assertStringList(guardrails.imeLogDerivedEventTypes ?? [], 'imeLogDerivedEventTypes');
|
||||
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
|
||||
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
|
||||
}
|
||||
@@ -207,6 +208,14 @@ export const ALLOWED_FILE_PREFIXES: readonly string[] = [
|
||||
${flatArray(guardrails.filePrefixes)}
|
||||
];
|
||||
|
||||
// Folders under the signed-in user's profile, RELATIVE to the profile root. The agent
|
||||
// resolves %LOGGED_ON_USER_PROFILE% at runtime; the portal validates against a placeholder
|
||||
// profile. These cannot live in ALLOWED_FILE_PREFIXES — that list is matched literally
|
||||
// against the already-expanded path, where the user name is unknown at authoring time.
|
||||
export const USER_PROFILE_FILE_PREFIXES: readonly string[] = [
|
||||
${flatArray(guardrails.userProfileFilePrefixes ?? [])}
|
||||
];
|
||||
|
||||
export const ALLOWED_WMI_QUERY_PREFIXES: readonly string[] = [
|
||||
${flatArray(guardrails.wmiQueryPrefixes)}
|
||||
];
|
||||
|
||||
@@ -14,6 +14,22 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeSignature {
|
||||
param([string]$Status, [string]$Subject)
|
||||
$cert = if ($Subject) {
|
||||
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
|
||||
} else { $null }
|
||||
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
||||
}
|
||||
|
||||
function New-FakePayload {
|
||||
param([string[]]$Files)
|
||||
$dir = Join-Path $TestDrive ([guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
foreach ($f in $Files) { Set-Content -Path (Join-Path $dir $f) -Value 'x' }
|
||||
return $dir
|
||||
}
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
@@ -305,3 +321,93 @@ Describe 'logging purity' {
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-UnverifiedAgentBinaries' {
|
||||
# The publisher gate on the extracted payload. What matters is the DECISION: which
|
||||
# payload is accepted, which is refused, and which files are looked at at all.
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
}
|
||||
|
||||
It 'accepts a payload where every binary of ours is validly signed by us' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'checks the binaries we author and leaves third-party assemblies alone' {
|
||||
$dir = New-FakePayload -Files @(
|
||||
'AutopilotMonitor.Agent.exe',
|
||||
'AutopilotMonitor.Agent.exe.config',
|
||||
'AutopilotMonitor.Shared.dll',
|
||||
'Newtonsoft.Json.dll',
|
||||
'System.Management.Automation.dll'
|
||||
)
|
||||
$script:checked = @()
|
||||
Mock Get-AuthenticodeSignature {
|
||||
$script:checked += (Split-Path $FilePath -Leaf)
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
}
|
||||
|
||||
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
|
||||
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
}
|
||||
|
||||
It 'refuses an unsigned agent executable' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses a tampered binary (hash mismatch)' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses a signature whose chain does not validate' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses a validly signed binary from a different publisher' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses the whole payload when a single DLL of ours is swapped' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
|
||||
Mock Get-AuthenticodeSignature {
|
||||
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
|
||||
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
|
||||
} else {
|
||||
New-FakeSignature -Status 'Valid' -Subject $script:ours
|
||||
}
|
||||
}
|
||||
$rejected = Get-UnverifiedAgentBinaries -Path $dir
|
||||
$rejected.Count | Should -Be 1
|
||||
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
|
||||
}
|
||||
|
||||
It 'refuses when the signature check itself throws' {
|
||||
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
|
||||
Mock Get-AuthenticodeSignature { throw 'access denied' }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses an empty payload instead of passing it' {
|
||||
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
|
||||
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publisher pin' {
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
|
||||
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
5. Verifies the Authenticode signature and publisher of every binary we author
|
||||
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
7. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
@@ -76,6 +77,11 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
|
||||
payload must carry a valid Authenticode signature from the
|
||||
expected publisher before --install runs. The SHA-256 manifest
|
||||
check stays and is unchanged; it proves the bytes, the signature
|
||||
proves the author.
|
||||
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
@@ -128,7 +134,12 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.4"
|
||||
$ScriptVersion = "2.5"
|
||||
|
||||
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
|
||||
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
|
||||
# the loader pins for this script.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -329,6 +340,56 @@ function Get-BootstrapDecision {
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Returns the list of reasons why the extracted payload is not ours; an empty result means
|
||||
# every binary we author carries a valid Authenticode signature from the expected publisher.
|
||||
# Third-party assemblies keep their own publisher's signature and are not checked here.
|
||||
# Any error is a rejection: an unreadable signature is an unverified binary.
|
||||
function Get-UnverifiedAgentBinaries {
|
||||
param([string]$Path)
|
||||
|
||||
$rejected = @()
|
||||
$ourBinaries = @(
|
||||
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension -in @('.exe', '.dll') }
|
||||
)
|
||||
|
||||
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
|
||||
# recognise, and the alternative would be a gate that silently approves an empty folder.
|
||||
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
|
||||
# a bare string, and the caller reads .Count and indexes into it.
|
||||
if ($ourBinaries.Count -eq 0) {
|
||||
return , @("no AutopilotMonitor binaries found in $Path")
|
||||
}
|
||||
|
||||
foreach ($binary in $ourBinaries) {
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
$rejected += "$($binary.Name): signature check returned nothing"
|
||||
continue
|
||||
}
|
||||
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
$rejected += "$($binary.Name): signature status $($sig.Status)"
|
||||
continue
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
$rejected += "$($binary.Name): unexpected publisher $subject"
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
@@ -445,6 +506,20 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The expected publisher travels inside THIS script, which the
|
||||
# loader verified before running it, so the chain from the file an administrator
|
||||
# assigns to the process that starts here stays publisher-anchored end to end.
|
||||
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
|
||||
# risk being removed.
|
||||
Write-Log "Verifying Authenticode signatures of the agent binaries..."
|
||||
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
|
||||
if ($unverified.Count -gt 0) {
|
||||
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
|
||||
}
|
||||
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Pester tests (Pester 5+) for the stage-1 loader Start-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the loader from
|
||||
# executing, so loading it here is side-effect free. Network and signature checks
|
||||
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
||||
# does it refuse, and how often does it retry.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeSignature {
|
||||
param([string]$Status, [string]$Subject)
|
||||
$cert = if ($Subject) {
|
||||
[pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' }
|
||||
} else { $null }
|
||||
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-BootstrapSignature' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
}
|
||||
|
||||
It 'accepts a valid signature from the expected publisher' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
||||
}
|
||||
|
||||
# An EV subject is a line of street address and registration numbers; the log gets the CN.
|
||||
It 'logs the signer CN, not the whole subject' {
|
||||
Mock Get-AuthenticodeSignature {
|
||||
New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach am Main, C=DE, SERIALNUMBER=HRB 12381'
|
||||
}
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue
|
||||
Should -Invoke Write-Log -ParameterFilter { $Message -eq 'Signature status: Valid. Signer: glueckkanja AG' }
|
||||
}
|
||||
|
||||
It 'refuses an unsigned script' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a tampered script (hash mismatch)' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a signature whose chain does not validate' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
# The whole point of the pinned publisher: a validly signed script from someone else
|
||||
# is exactly what an attacker with any code-signing certificate can produce.
|
||||
It 'refuses a valid signature from a different publisher' {
|
||||
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses when the signature check itself fails' {
|
||||
Mock Get-AuthenticodeSignature { throw 'file not found' }
|
||||
Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-VerifiedBootstrapScript' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Invoke-WebRequest { }
|
||||
}
|
||||
|
||||
It 'returns after the first attempt when the download verifies' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'gives up after three attempts when verification keeps failing' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
# A truncated or proxy-mangled body fails verification, so the retry has to cover the
|
||||
# download AND the check, not just the transport.
|
||||
It 'retries a failed verification and accepts a later attempt' {
|
||||
$script:calls = 0
|
||||
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'retries a transport failure' {
|
||||
$script:downloads = 0
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'never executes anything when no attempt verifies' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish contract' {
|
||||
BeforeAll {
|
||||
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw
|
||||
}
|
||||
|
||||
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
||||
# literal and fails the publish when it is gone. Catching that here means the PR fails
|
||||
# instead of the publish.
|
||||
It 'carries the dev-render anchor the publisher substitutes' {
|
||||
$anchor = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
||||
$script:loaderText.Contains($anchor) | Should -BeTrue
|
||||
}
|
||||
|
||||
# The publisher parses this to write version.json.loaderVersion and to run the
|
||||
# version-bump guard.
|
||||
It 'declares a parsable ScriptVersion' {
|
||||
$script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
$script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue
|
||||
}
|
||||
|
||||
# The bootstrap MSI installs this same file and runs it with -LogFileName so the delivery
|
||||
# channel stays visible in a diagnostics package. Renaming the parameter on either side
|
||||
# would leave the MSI channel silently logging under the wrong name.
|
||||
It 'keeps the -LogFileName contract the bootstrap MSI relies on' {
|
||||
$script:loaderText -match '\[string\]\$LogFileName' | Should -BeTrue
|
||||
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
|
||||
installs and runs -- one implementation for both delivery channels; the MSI is only the
|
||||
packaging. It carries no enrollment logic at all: it downloads the published
|
||||
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
|
||||
in this process.
|
||||
|
||||
Why two stages: the installer holds every guard, relax rule and download path, and those
|
||||
change. Whoever assigns the installer directly has to replace their Intune copy each
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
|
||||
.PARAMETER LogFileName
|
||||
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
|
||||
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
|
||||
names, so do not rename them.
|
||||
|
||||
.PARAMETER BootstrapArguments
|
||||
Everything else is passed through to the installer unchanged, so any parameter the
|
||||
installer accepts can be set here without this file knowing about it.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
|
||||
logic; -LogFileName keeps the channel visible in diagnostics.
|
||||
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$LogFileName = "bootstrap-loader.log",
|
||||
|
||||
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$BootstrapArguments
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.2"
|
||||
|
||||
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
|
||||
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
function Test-BootstrapSignature {
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Log "Signature check failed to run: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
Write-Log "Signature check returned nothing."
|
||||
return $false
|
||||
}
|
||||
|
||||
# The full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for a rejection.
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
|
||||
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
|
||||
Write-Log "Signature status: $($sig.Status). Signer: $signer"
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Log "REJECTED: expected signature status 'Valid'."
|
||||
return $false
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
|
||||
if (Test-BootstrapSignature -Path $Destination) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($attempt -lt $DownloadAttempts) {
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
@@ -34,6 +34,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
new Regex("\\[\"eventType\"\\]\\s*=\\s*\"([a-z][a-z0-9_]*)\"", RegexOptions.Compiled),
|
||||
};
|
||||
|
||||
// Multi-line callback shape (2026-09-04, D-072 gap): `OnTraceEvent?.Invoke(` at the end
|
||||
// of one line, the event-type literal alone on the NEXT line. The single-line patterns
|
||||
// above never saw `desktop_real_user_detected` / `desktop_excluded_user` this way.
|
||||
private static readonly Regex InvokeOpener = new Regex("\\.Invoke\\(\\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex BareLiteralLine = new Regex("^\\s*\"([a-z][a-z0-9_]*)\"\\s*,?\\s*$", RegexOptions.Compiled);
|
||||
|
||||
// The reducer/param key name itself ("eventType") and the like are not event types.
|
||||
private static readonly HashSet<string> NotEventTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -59,33 +65,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
Assert.True(Directory.Exists(full), $"V2 source dir not found: {full}");
|
||||
|
||||
foreach (var file in EnumerateSourceFiles(full))
|
||||
{
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
|
||||
continue;
|
||||
if (line.Contains("const ")) continue;
|
||||
|
||||
foreach (var pattern in EmitPatterns)
|
||||
{
|
||||
foreach (Match m in pattern.Matches(line))
|
||||
{
|
||||
var literal = m.Groups[1].Value;
|
||||
if (NotEventTypes.Contains(literal)) continue;
|
||||
if (!known.Contains(literal))
|
||||
{
|
||||
offenders.Add(
|
||||
$"{Path.GetFileName(file)}:{i + 1} \"{literal}\" -> {line.Trim()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offenders.AddRange(FindOffenders(Path.GetFileName(file), File.ReadAllLines(file), known));
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
@@ -95,6 +75,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_detects_multiline_Invoke_literal()
|
||||
{
|
||||
// Mutation proof for the multi-line shape: a synthetic emit site with an unknown
|
||||
// literal on the line after `OnTraceEvent?.Invoke(` must be reported; the same
|
||||
// shape with a known type must not.
|
||||
var known = new HashSet<string>(StringComparer.Ordinal) { "desktop_arrived" };
|
||||
string[] bad =
|
||||
{
|
||||
" OnTraceEvent?.Invoke(",
|
||||
" \"desktop_not_a_real_type\",",
|
||||
" $\"Real user desktop detected\",",
|
||||
};
|
||||
string[] good =
|
||||
{
|
||||
" OnTraceEvent?.Invoke(",
|
||||
" \"desktop_arrived\",",
|
||||
};
|
||||
|
||||
var offender = Assert.Single(FindOffenders("X.cs", bad, known));
|
||||
Assert.Contains("desktop_not_a_real_type", offender);
|
||||
Assert.Empty(FindOffenders("X.cs", good, known));
|
||||
}
|
||||
|
||||
private static List<string> FindOffenders(string fileName, string[] lines, HashSet<string> known)
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
|
||||
continue;
|
||||
if (line.Contains("const ")) continue;
|
||||
|
||||
foreach (var pattern in EmitPatterns)
|
||||
{
|
||||
foreach (Match m in pattern.Matches(line))
|
||||
Check(m.Groups[1].Value, line, i);
|
||||
}
|
||||
|
||||
if (i > 0 && InvokeOpener.IsMatch(lines[i - 1]))
|
||||
{
|
||||
var bare = BareLiteralLine.Match(line);
|
||||
if (bare.Success) Check(bare.Groups[1].Value, line, i);
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
|
||||
void Check(string literal, string line, int index)
|
||||
{
|
||||
if (NotEventTypes.Contains(literal)) return;
|
||||
if (!known.Contains(literal))
|
||||
offenders.Add($"{fileName}:{index + 1} \"{literal}\" -> {line.Trim()}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All string-const values declared on <see cref="Constants.EventTypes"/>.</summary>
|
||||
private static HashSet<string> KnownEventTypes()
|
||||
{
|
||||
@@ -113,18 +152,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
private static string RepoRoot() => Harness.RepoPaths.Root();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>Repository paths for tests that read or write checked-in files (guards, fixtures).</summary>
|
||||
internal static class RepoPaths
|
||||
{
|
||||
/// <summary>Walks up from the test binary to the directory holding <c>AutopilotMonitor.sln</c>.</summary>
|
||||
public static string Root()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary><c>tests/fixtures/<name></c> under the repo root.</summary>
|
||||
public static string Fixtures(string name) => Path.Combine(Root(), "tests", "fixtures", name);
|
||||
}
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@ using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031 // SpinWait.SpinUntil for uploader-batch-arrival assertion
|
||||
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Entra user affinity after the real-user desktop (2026-09-04, session a7140f98). The
|
||||
/// contracts that matter: arm only on Hybrid devices and only at the desktop; the first
|
||||
/// IME user-token line AFTER the desktop cancels and posts <c>ime_user_token_acquired</c>;
|
||||
/// token lines from before the desktop (device phase) are ignored; the timer posts
|
||||
/// <c>entra_user_affinity_pending</c> with the failure codes seen since the desktop; both
|
||||
/// emissions are single-shot per process.
|
||||
/// </summary>
|
||||
public sealed class EntraUserAffinityDetectorTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 4, 13, 54, 6, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
|
||||
public InformationalEventPost Post { get; }
|
||||
public bool Hybrid { get; set; } = true;
|
||||
public bool PlaceholderActive { get; set; } = true;
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
|
||||
Post = new InformationalEventPost(Ingress, Clock);
|
||||
}
|
||||
|
||||
public EntraUserAffinityDetector Build() => new EntraUserAffinityDetector(
|
||||
Post, Logger,
|
||||
isHybridJoinProbe: () => Hybrid,
|
||||
placeholderActiveProbe: () => PlaceholderActive,
|
||||
delay: TimeSpan.FromMinutes(10),
|
||||
utcNow: () => Clock.UtcNow);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
private static FakeSignalIngressSink.PostedSignal? Find(FakeSignalIngressSink ingress, string eventType) =>
|
||||
ingress.Posted.FirstOrDefault(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Desktop_on_hybrid_device_arms()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
|
||||
Assert.True(d.IsArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Desktop_on_non_hybrid_device_does_not_arm()
|
||||
{
|
||||
using var f = new Fixture { Hybrid = false };
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.False(d.IsArmedForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timer_without_token_emits_pending_with_failure_codes()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(1));
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(3));
|
||||
d.NotifyTokenFailureLine("3400073247", f.Clock.UtcNow.AddMinutes(5));
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal("Warning", pending!.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", pending.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("EntraUserAffinityDetector", pending.Payload[SignalPayloadKeys.Source]);
|
||||
Assert.Equal("10", pending.Payload["delayMinutes"]);
|
||||
Assert.Equal("timer_fired", pending.Payload["reason"]);
|
||||
Assert.Equal("3", pending.Payload["tokenFailureCount"]);
|
||||
Assert.Equal("3399548929,3400073247", pending.Payload["tokenFailureCodes"]);
|
||||
Assert.Equal("true", pending.Payload["placeholderActive"]);
|
||||
Assert.Equal("10.0", pending.Payload["minutesSinceDesktop"]);
|
||||
Assert.True(d.HasFiredForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_after_desktop_cancels_and_posts_token_acquired()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddSeconds(30));
|
||||
var tokenAt = f.Clock.UtcNow.AddMinutes(3.7);
|
||||
d.NotifyUserTokenAcquired(tokenAt);
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
var acquired = Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired);
|
||||
Assert.NotNull(acquired);
|
||||
Assert.Equal("Info", acquired!.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("3.7", acquired.Payload["minutesAfterDesktop"]);
|
||||
Assert.Equal("1", acquired.Payload["tokenFailuresBeforeSuccess"]);
|
||||
Assert.Equal(tokenAt, acquired.OccurredAtUtc);
|
||||
Assert.True(d.TokenAcquiredPostedForTest);
|
||||
Assert.False(d.HasFiredForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_lines_from_before_the_desktop_are_ignored()
|
||||
{
|
||||
// Device-phase token lines ("Failed to get AAD token" is routine there; a success
|
||||
// can be the device check-in) must neither count nor cancel — the 2-min tolerance
|
||||
// only covers the desktop poll interval.
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(-20));
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(-15));
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal("0", pending!.Payload!["tokenFailureCount"]);
|
||||
Assert.Equal(string.Empty, pending.Payload["tokenFailureCodes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_within_desktop_poll_tolerance_counts_as_after_desktop()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddSeconds(-90));
|
||||
|
||||
Assert.NotNull(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
Assert.Equal("0.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_without_arm_is_ignored()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pending_fires_once_and_a_late_token_still_resolves_it_once()
|
||||
{
|
||||
// A token that arrives AFTER the warning is the most useful thing the timeline can
|
||||
// show ("affinity came late") and lets the backend rule drop the finding — so it is
|
||||
// posted, but like the warning only once per process.
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyRealUserDesktop();
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
d.TriggerFromTest();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(1));
|
||||
|
||||
Assert.Single(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.Single(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.ImeUserTokenAcquired);
|
||||
Assert.Equal("10.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_probe_throwing_does_not_arm()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = new EntraUserAffinityDetector(
|
||||
f.Post, f.Logger,
|
||||
isHybridJoinProbe: () => throw new InvalidOperationException("registry"),
|
||||
utcNow: () => f.Clock.UtcNow);
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
|
||||
Assert.False(d.IsArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_after_arm_does_not_throw_and_blocks_emission()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.Dispose();
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -456,5 +456,124 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// %LOGGED_ON_USER_PROFILE% exception (userProfilePath overload)
|
||||
//
|
||||
// The token lifts the C:\Users hard block for AppData\Local and
|
||||
// AppData\Roaming — and nothing more. The allowlist still decides,
|
||||
// which is what the cases below pin down: the exception is a gate the
|
||||
// allowlist has to open, never an allowance of its own.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private const string TestProfile = @"C:\Users\TestUser";
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_AppDataPath_WithoutAllowlistEntry_IsStillBlocked()
|
||||
{
|
||||
// The lifted hard block alone admits nothing: without a matching
|
||||
// userProfileFilePrefixes entry the path falls through to the
|
||||
// allowlist and is refused there.
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + @"\AppData\Local\UnlistedVendor\app.log",
|
||||
unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_OutsideAppData_IsBlockedByTheHardBlock()
|
||||
{
|
||||
foreach (var target in new[] { @"\Desktop\secret.txt", @"\Documents\notes.txt", @"\ntuser.dat" })
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + target, unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_NullProfile_KeepsTheUsersHardBlock()
|
||||
{
|
||||
// No interactive user detected — the exception cannot apply and the
|
||||
// path stays under the plain C:\Users block.
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Users\TestUser\AppData\Local\UnlistedVendor\app.log",
|
||||
unrestrictedMode: false, userProfilePath: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_TraversalOutOfAppData_IsBlocked()
|
||||
{
|
||||
// Spelling Documents as a traversal out of AppData must not survive
|
||||
// Path.GetFullPath normalisation.
|
||||
const string traversal = @"C:\Users\TestUser\AppData\Local\..\..\Documents\secret.txt";
|
||||
|
||||
Assert.Equal(@"C:\Users\TestUser\Documents\secret.txt", Path.GetFullPath(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
traversal, unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_ForeignProfile_IsBlockedEvenUnderAppData()
|
||||
{
|
||||
// The exception is scoped to the DETECTED profile — a rule must not
|
||||
// reach a second user's AppData by spelling the path out.
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Users\OtherUser\AppData\Local\UnlistedVendor\app.log",
|
||||
unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_AllowlistedVendorFolder_IsAllowed()
|
||||
{
|
||||
// AppData\Local\RealmJoin is on userProfileFilePrefixes: the file itself, a
|
||||
// subfolder below it, and the folder itself all pass.
|
||||
foreach (var target in new[] { @"\tray.log", @"\Logs\rj.log", "" })
|
||||
{
|
||||
Assert.True(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + @"\AppData\Local\RealmJoin" + target,
|
||||
unrestrictedMode: false, userProfilePath: TestProfile),
|
||||
$"expected allowed: ...\\RealmJoin{target}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_SiblingOfAnAllowlistedVendorFolder_IsBlocked()
|
||||
{
|
||||
// Segment-bounded: "RealmJoin" must not admit "RealmJoinSomethingElse".
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + @"\AppData\Local\RealmJoinSomethingElse\tray.log",
|
||||
unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_AllowlistedVendorFolder_InAForeignProfile_IsBlocked()
|
||||
{
|
||||
// The allowlist is anchored to the DETECTED profile, not to C:\Users\*.
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Users\OtherUser\AppData\Local\RealmJoin\tray.log",
|
||||
unrestrictedMode: false, userProfilePath: TestProfile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_AllowlistedVendorFolder_WithoutADetectedUser_IsBlocked()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Users\TestUser\AppData\Local\RealmJoin\tray.log",
|
||||
unrestrictedMode: false, userProfilePath: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserProfile_AppDataPath_IsAllowedInUnrestrictedMode()
|
||||
{
|
||||
// Where the exception does bite today: unrestricted mode returns
|
||||
// before the allowlist, so the lifted hard block is the whole story.
|
||||
Assert.True(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + @"\AppData\Local\UnlistedVendor\app.log",
|
||||
unrestrictedMode: true, userProfilePath: TestProfile));
|
||||
|
||||
// ...but only under AppData — Documents stays blocked even there.
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(
|
||||
TestProfile + @"\Documents\secret.txt",
|
||||
unrestrictedMode: true, userProfilePath: TestProfile));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="GatherRule.EnrichErrorCodes"/> is an opt-in the backend honours at response time:
|
||||
/// the agent stamps <c>enrichErrorCodes: true</c> into the event data of a rule that set it and
|
||||
/// nothing at all otherwise (the default payload stays as it was).
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorEnrichErrorCodesTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorEnrichErrorCodesTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private EnrollmentEvent SingleEvent()
|
||||
{
|
||||
lock (_eventsGate) return Assert.Single(_events);
|
||||
}
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
lock (_eventsGate) if (_events.Count >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
lock (_eventsGate) return _events.Count >= count;
|
||||
}
|
||||
|
||||
private static GatherRule StartupRule(bool enrichErrorCodes) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-EEC-001",
|
||||
Title = "enrich test",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
EnrichErrorCodes = enrichErrorCodes,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void StampRuleMarkers_AddsMarkerOnlyWhenOptedIn()
|
||||
{
|
||||
var optedIn = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: true), optedIn);
|
||||
Assert.Equal(true, optedIn[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
|
||||
var defaultRule = new Dictionary<string, object> { ["exitCode"] = "1603" };
|
||||
GatherRuleExecutor.StampRuleMarkers(StartupRule(enrichErrorCodes: false), defaultRule);
|
||||
Assert.False(defaultRule.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
Assert.Single(defaultRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_StampsMarker_WhenRuleOptsIn()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: true) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
var evt = SingleEvent();
|
||||
Assert.Equal(GatherRuleExecutor.SourceName, evt.Source);
|
||||
Assert.Equal(true, evt.Data[Constants.GatherRuleDataKeys.EnrichErrorCodes]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecuteRule_LeavesPayloadUnchanged_ByDefault()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { StartupRule(enrichErrorCodes: false) });
|
||||
|
||||
Assert.True(_executor.WaitForStartupRules(30));
|
||||
Assert.True(WaitForEventCount(1));
|
||||
Assert.False(SingleEvent().Data.ContainsKey(Constants.GatherRuleDataKeys.EnrichErrorCodes));
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -73,6 +73,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.Equal("true", info.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(304, "join phase")]
|
||||
[InlineData(305, "authentication phase")]
|
||||
public void ProcessHelloEvent_304_305_emit_device_registration_event_with_immediate_upload(int eventId, string phaseLabel)
|
||||
{
|
||||
// Hybrid affinity companion (2026-09-04): UDR 304/305 ride the already-armed
|
||||
// watcher and surface as device_registration_event — a Warning, flushed at once,
|
||||
// and never a Hello completion.
|
||||
using var f = new Fixture();
|
||||
|
||||
f.Tracker.ProcessHelloEvent(eventId, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
|
||||
var info = f.InfoEvent("device_registration_event");
|
||||
Assert.Equal("Warning", info.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Contains(phaseLabel, info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("hello_provisioning"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_358_willlaunch_is_suppressed()
|
||||
{
|
||||
|
||||
+52
@@ -194,6 +194,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.NotNull(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Real-user desktop cancel path (2026-09-04, session a7140f98)
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Real_user_desktop_after_Arm_cancels_emission()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.Arm();
|
||||
detector.NotifyRealUserDesktop();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
Assert.True(detector.IsCancelledByDesktopForTest);
|
||||
Assert.False(detector.HasFiredForTest);
|
||||
Assert.Null(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_user_desktop_before_Arm_short_circuits_Arm()
|
||||
{
|
||||
// The DAD first poll runs seconds after start; the arm request comes from the
|
||||
// runtime host slightly later. A desktop seen first must make the arm a no-op.
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.NotifyRealUserDesktop();
|
||||
detector.Arm();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
Assert.False(detector.IsArmedForTest);
|
||||
Assert.Null(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emission_reports_desktop_absence_and_placeholder_as_fact()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.Arm();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
var info = FindHybridLoginPending(f.Ingress);
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal("false", info!.Payload!["realUserDesktopSeen"]);
|
||||
Assert.Equal("false", info.Payload["placeholderActive"]); // watcher never saw JoinInfo in this fixture
|
||||
Assert.Contains("no real-user desktop", info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.DoesNotContain("placeholder still active", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle / Dispose
|
||||
// ============================================================================
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Backlog era anchoring (2026-09-04, session a7140f98). The property under test: the
|
||||
/// bootstrap execution record — stdout in AgentExecutor.log, policy result in the service
|
||||
/// log — measured against the install marker anchors exactly the era it sits in; a SKIP
|
||||
/// re-run, a missing marker, an MSI bootstrap and an off-grid residual anchor nothing; the
|
||||
/// anchor never crosses an "EMS Agent Started" boundary; sibling files join by local time
|
||||
/// only while the service eras are unambiguous.
|
||||
/// </summary>
|
||||
public sealed class ImeLogEraPreScanTests
|
||||
{
|
||||
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
|
||||
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
|
||||
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
|
||||
{
|
||||
var local = utcInstant + writerOffset;
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
|
||||
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private static string ScriptStart(DateTime utc, TimeSpan offset) =>
|
||||
Entry($@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\00000000-0000-0000-0000-000000000000_{PolicyId}.ps1", utc, offset);
|
||||
|
||||
private static string BootstrapStdout(DateTime utc, TimeSpan offset, string tail = "===== Bootstrap Completed Successfully =====") =>
|
||||
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\nBootstrap script version: v2.0\nAgent install mode completed successfully\n" + tail + ", error = ", utc, offset);
|
||||
|
||||
private static string PolicyResult(DateTime utc, TimeSpan offset, string policyId = PolicyId) =>
|
||||
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {policyId}, policy result = Success", utc, offset);
|
||||
|
||||
private static string ServiceStarted(DateTime utc, TimeSpan offset) => Entry("EMS Agent Started", utc, offset);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
private readonly List<ImeLogEraPreScan.ScanInput> _inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
|
||||
public string Write(string fileName, params string[] entries)
|
||||
{
|
||||
var path = Path.Combine(Tmp.Path, fileName);
|
||||
File.WriteAllText(path, string.Join(Environment.NewLine, entries) + Environment.NewLine);
|
||||
_inputs.Add(new ImeLogEraPreScan.ScanInput(path, 0));
|
||||
return path;
|
||||
}
|
||||
|
||||
public long OffsetOfLine(string fileName, int lineIndex)
|
||||
{
|
||||
// Entries are written line by line; the byte offset of entry N is the sum of the
|
||||
// preceding lines (CRLF-agnostic through Environment.NewLine).
|
||||
var path = Path.Combine(Tmp.Path, fileName);
|
||||
var lines = File.ReadAllLines(path);
|
||||
long offset = 0;
|
||||
for (var i = 0; i < lineIndex; i++)
|
||||
offset += System.Text.Encoding.UTF8.GetByteCount(lines[i]) + Environment.NewLine.Length;
|
||||
return offset;
|
||||
}
|
||||
|
||||
public CmTraceEraTable Build(DateTime? deployed) =>
|
||||
ImeLogEraPreScan.Build(_inputs, deployed, ImeLogTracker.MaxEntryBytes, null);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bootstrap_record_anchors_service_era_and_executor_block_only_within_their_boundaries()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log",
|
||||
ScriptStart(Deployed.AddSeconds(-20), Pacific),
|
||||
BootstrapStdout(Deployed.AddMilliseconds(200), Pacific),
|
||||
Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(5), Pacific),
|
||||
Entry("write output done. output = other script, error = ", Deployed.AddMinutes(5).AddSeconds(2), Pacific));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(-1), Pacific), // line 0, era 0
|
||||
PolicyResult(Deployed.AddMilliseconds(260), Pacific), // line 1, anchor
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(10), Pacific), // line 2, era 0
|
||||
ServiceStarted(Deployed.AddMinutes(20), Eastern), // line 3, era 1 (post-boot, new zone)
|
||||
Entry("Successfully get the token", Deployed.AddMinutes(21), Eastern)); // line 4, era 1
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(2, table.AnchoredEraCount);
|
||||
Assert.False(table.TransferAmbiguous);
|
||||
|
||||
// Service era 0: every offset before "EMS Agent Started" resolves to the Pacific offset.
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 0), out var off0, out var kind0));
|
||||
Assert.Equal(Pacific, off0);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, kind0);
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 2), out var off2, out _));
|
||||
Assert.Equal(Pacific, off2);
|
||||
|
||||
// Era 1 has no anchor of its own — the Pacific anchor must not leak across the boundary.
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 3), out _, out _));
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 4), out _, out _));
|
||||
|
||||
// Executor: only the bootstrap execution block is anchored, not the next script's block.
|
||||
Assert.True(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 0), out var offX, out var kindX));
|
||||
Assert.Equal(Pacific, offX);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, kindX);
|
||||
Assert.False(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 5), out _, out _));
|
||||
|
||||
// Transfer for in-process sibling files: a local time inside era 0's range resolves,
|
||||
// one inside era 1's range does not.
|
||||
Assert.True(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + Pacific, out var offT, out var kindT));
|
||||
Assert.Equal(Pacific, offT);
|
||||
Assert.EndsWith("/service-era-transfer", kindT);
|
||||
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(25) + Eastern, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_deployed_marker_nothing_is_anchored()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
|
||||
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
|
||||
|
||||
var table = f.Build(null);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Msi_bootstrap_without_stdout_record_anchors_nothing()
|
||||
{
|
||||
// The MSI runner never passes through AgentExecutor; without the policy id the
|
||||
// service log's result lines cannot be told apart from any other script's.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(3), Pacific));
|
||||
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skip_rerun_record_is_never_offered_and_first_record_off_grid_anchors_nothing()
|
||||
{
|
||||
// The install run is the FIRST bootstrap record; a SKIP re-run 15 minutes later
|
||||
// would round onto the grid with zero residual and anchor a wrong offset.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log",
|
||||
ScriptStart(Deployed.AddMinutes(-9), Pacific),
|
||||
BootstrapStdout(Deployed.AddMinutes(-8), Pacific), // first record: residual 8 min → rejected
|
||||
ScriptStart(Deployed.AddMinutes(15).AddSeconds(-2), Pacific),
|
||||
BootstrapStdout(Deployed.AddMinutes(15), Pacific)); // would be a perfect (wrong) grid hit
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
PolicyResult(Deployed.AddMinutes(-8), Pacific),
|
||||
PolicyResult(Deployed.AddMinutes(15), Pacific));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_era_continues_across_a_log_rotation()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
|
||||
f.Write("IntuneManagementExtension-20260904-133000.log",
|
||||
PolicyResult(Deployed.AddMilliseconds(260), Pacific),
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(2), Pacific));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(12), Pacific), // same process, rotated file
|
||||
ServiceStarted(Deployed.AddMinutes(20), Eastern));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", 0, out var off, out _));
|
||||
Assert.Equal(Pacific, off);
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlapping_service_era_local_ranges_disable_the_transfer()
|
||||
{
|
||||
// A westward zone change between two service starts makes era 1 start "before"
|
||||
// era 0 in local time; the sibling-file transfer must refuse rather than guess.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), TimeSpan.FromHours(2)), BootstrapStdout(Deployed.AddMilliseconds(200), TimeSpan.FromHours(2)));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
ServiceStarted(Deployed.AddMinutes(-30), TimeSpan.FromHours(2)),
|
||||
PolicyResult(Deployed.AddMilliseconds(260), TimeSpan.FromHours(2)),
|
||||
ServiceStarted(Deployed.AddMinutes(20), TimeSpan.FromHours(-7)));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.True(table.TransferAmbiguous);
|
||||
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + TimeSpan.FromHours(2), out _, out _));
|
||||
// Offset-based lookup is unaffected.
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out var off, out _));
|
||||
Assert.Equal(TimeSpan.FromHours(2), off);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deployment_marker_parses_roundtrip_utc()
|
||||
{
|
||||
Assert.Equal(Deployed, AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("2026-09-04T13:31:49.1000000Z"));
|
||||
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse(""));
|
||||
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("not a date"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -311,6 +311,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
"[Win32App] Admin did NOT set mapping for lpExitCode: 1603 of app: " + Guid1,
|
||||
new[] { "exitCode=1603", "id=" + Guid1 }
|
||||
};
|
||||
// Win32AppInstaller.cs "[Win32App] lpExitCode is defined as {0}", enum AppReturnCodeType
|
||||
// (identical 1.50–1.105).
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-EXITCODE-CLASS",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot",
|
||||
new[] { "exitCodeClass=SoftReboot" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-GRS-SKIP",
|
||||
@@ -361,6 +369,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Success")]
|
||||
[InlineData("SoftReboot")]
|
||||
[InlineData("HardReboot")]
|
||||
[InlineData("Retry")]
|
||||
[InlineData("Failed")]
|
||||
public void IME_EXITCODE_CLASS_captures_every_AppReturnCodeType_member(string member)
|
||||
{
|
||||
var m = Compile(Get("IME-EXITCODE-CLASS")).Match("[Win32App] lpExitCode is defined as " + member);
|
||||
Assert.True(m.Success);
|
||||
Assert.Equal(member, m.Groups["exitCodeClass"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IME_EXITCODE_CLASS_ignores_the_no_mapping_warning_and_raw_numeric_types()
|
||||
{
|
||||
// The default branch logs "... defined as {0}, fall into default now." and an admin
|
||||
// type outside the enum prints as a raw number; neither is a class the app carries.
|
||||
var regex = Compile(Get("IME-EXITCODE-CLASS"));
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as 5, fall into default now.");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as SoftReboot, fall into default now.");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode is defined as 5");
|
||||
Assert.DoesNotMatch(regex, "[Win32App] lpExitCode 3010");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PS_SCRIPT_CONTEXT_no_longer_fires_on_Win32App_detection_script_lines()
|
||||
{
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end through the tracker: a restart/first-sight backlog whose pre-reboot era was
|
||||
/// written under the OOBE default zone (a7140f98: Pacific) while the agent believes the
|
||||
/// zone Windows set later (Eastern). With the bootstrap record anchoring that era, every
|
||||
/// pre-reboot line resolves to its true instant with origin <c>era-anchored</c>; the
|
||||
/// post-reboot era without an anchor keeps the marked fallback; in-process sibling files
|
||||
/// join by local time; a fresh pass still self-anchors per line.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerEraAnchoringTests
|
||||
{
|
||||
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
|
||||
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
|
||||
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
|
||||
|
||||
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-ALL", Category = "always", Enabled = true,
|
||||
Pattern = @"marker (?<n>\d+)",
|
||||
Action = "noop",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
|
||||
{
|
||||
var local = utcInstant + writerOffset;
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
|
||||
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public DateTime Now { get; set; } = Deployed.AddMinutes(30);
|
||||
public List<(string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)> Resolved { get; } =
|
||||
new List<(string, DateTime?, CmTraceOffsetOrigin, int?, string)>();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: Patterns(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info));
|
||||
Tracker.UtcNowProvider = () => Now;
|
||||
Tracker.DeployedUtcProvider = () => Deployed;
|
||||
Tracker.OnPatternMatched += _ => Resolved.Add((
|
||||
Tracker.LastMatchedPatternId ?? string.Empty,
|
||||
Tracker.LastMatchedLogTimestamp,
|
||||
Tracker.LastMatchedSourceOffsetOrigin,
|
||||
Tracker.LastMatchedSourceOffsetMinutes,
|
||||
Tracker.LastMatchedEraAnchorKind));
|
||||
}
|
||||
|
||||
public void Append(string fileName, params string[] lines)
|
||||
=> File.AppendAllText(Path.Combine(_tmp.Path, fileName), string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backlog_lines_of_the_bootstrap_era_resolve_through_the_anchor_and_the_next_era_stays_on_fallback()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Append("AgentExecutor.log",
|
||||
Entry($@"Adding argument powershell with value C:\x\Policies\Scripts\u_{PolicyId}.ps1", Deployed.AddSeconds(-20), Pacific),
|
||||
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\n===== Bootstrap Completed Successfully =====, error = ", Deployed.AddMilliseconds(200), Pacific),
|
||||
Entry("marker 10", Deployed.AddSeconds(1), Pacific));
|
||||
h.Append("IntuneManagementExtension.log",
|
||||
Entry("marker 1", Deployed.AddMinutes(-1), Pacific),
|
||||
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {PolicyId}, policy result = Success", Deployed.AddMilliseconds(260), Pacific),
|
||||
Entry("marker 2", Deployed.AddMinutes(10), Pacific),
|
||||
Entry("EMS Agent Started", Deployed.AddMinutes(20), Eastern),
|
||||
Entry("marker 3", Deployed.AddMinutes(21), Eastern));
|
||||
h.Append("AppWorkload.log",
|
||||
Entry("marker 4", Deployed.AddMinutes(5), Pacific),
|
||||
Entry("marker 5", Deployed.AddMinutes(25), Eastern));
|
||||
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass(); // first sight: backlog, never fresh
|
||||
|
||||
var byMarker = new Dictionary<string, (string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)>();
|
||||
foreach (var r in h.Resolved) byMarker[r.Message] = r;
|
||||
Assert.Equal(6, h.Resolved.Count);
|
||||
|
||||
// Marker text is the pattern id (T-ALL) — index by resolution order instead.
|
||||
var r1 = h.Resolved[0]; // AgentExecutor marker 10 (files sort: AgentExecutor < AppWorkload < IntuneManagementExtension)
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r1.Origin);
|
||||
Assert.Equal(Deployed.AddSeconds(1), r1.Ts);
|
||||
Assert.Equal(-420, r1.Offset);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, r1.Anchor);
|
||||
|
||||
var r4 = h.Resolved[1]; // AppWorkload marker 4 — transfer by local time into the service era
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r4.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(5), r4.Ts);
|
||||
Assert.EndsWith("/service-era-transfer", r4.Anchor);
|
||||
|
||||
var r5 = h.Resolved[2]; // AppWorkload marker 5 — post-boot era, unanchored
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, r5.Origin);
|
||||
Assert.Equal(string.Empty, r5.Anchor);
|
||||
|
||||
var rm1 = h.Resolved[3]; // IME marker 1
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm1.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(-1), rm1.Ts);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, rm1.Anchor);
|
||||
|
||||
var rm2 = h.Resolved[4]; // IME marker 2
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm2.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(10), rm2.Ts);
|
||||
|
||||
var rm3 = h.Resolved[5]; // IME marker 3 — after "EMS Agent Started", no anchor
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, rm3.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fresh_lines_still_self_anchor_after_the_pre_scan()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 1", h.Now, Eastern));
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass();
|
||||
|
||||
h.Now = h.Now.AddSeconds(10);
|
||||
var writtenAt = h.Now.AddMilliseconds(-80);
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 2", writtenAt, Eastern));
|
||||
await h.Pass();
|
||||
|
||||
var last = h.Resolved[h.Resolved.Count - 1];
|
||||
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, last.Origin);
|
||||
Assert.Equal(writtenAt, last.Ts);
|
||||
Assert.Equal(string.Empty, last.Anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_marker_the_backlog_keeps_the_reader_zone_fallback()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.DeployedUtcProvider = () => null;
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 1", Deployed.AddMinutes(-1), Pacific));
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass();
|
||||
|
||||
Assert.Single(h.Resolved);
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, h.Resolved[0].Origin);
|
||||
Assert.Equal(0, h.Tracker.EraTable.AnchoredEraCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Return-code class of the installer exit code (IME "[Win32App] lpExitCode is defined as
|
||||
/// {AppReturnCodeType}"): captured by the shipped IME-EXITCODE-CLASS pattern into
|
||||
/// <see cref="AppPackageState.ExitCodeClass"/>, last value wins across a Retry loop, the
|
||||
/// no-mapping/default-branch variants are ignored, and the value survives the agent
|
||||
/// restart that a SoftReboot/HardReboot app causes (persistence round trip).
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerExitCodeClassTests
|
||||
{
|
||||
private const string AppId = "5c95bf94-1cf4-4629-88d1-3f616e7a405c";
|
||||
|
||||
// ---------------------------------------------------------------- AppPackageState
|
||||
|
||||
[Fact]
|
||||
public void UpdateExitCodeClass_last_value_wins_and_ignores_empty()
|
||||
{
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
Assert.Null(pkg.ExitCodeClass);
|
||||
|
||||
Assert.True(pkg.UpdateExitCodeClass("Retry"));
|
||||
Assert.True(pkg.UpdateExitCodeClass(" Success "));
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
Assert.False(pkg.UpdateExitCodeClass("Success"));
|
||||
Assert.False(pkg.UpdateExitCodeClass(""));
|
||||
Assert.False(pkg.UpdateExitCodeClass(null));
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEventData_carries_the_class_only_when_known()
|
||||
{
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
Assert.False(pkg.ToEventData().ContainsKey("exitCodeClass"));
|
||||
|
||||
pkg.UpdateExitCode("3010");
|
||||
pkg.UpdateExitCodeClass("SoftReboot");
|
||||
var data = pkg.ToEventData();
|
||||
Assert.Equal("3010", data["exitCode"]);
|
||||
Assert.Equal("SoftReboot", data["exitCodeClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restore_round_trips_the_class()
|
||||
{
|
||||
var pkg = AppPackageState.Restore(
|
||||
AppId, 0, "Contoso VPN",
|
||||
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
|
||||
new HashSet<string>(),
|
||||
AppInstallationState.Installed, true, 100, 0, 0,
|
||||
exitCode: "3010", exitCodeClass: "SoftReboot");
|
||||
|
||||
Assert.Equal("SoftReboot", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shipped pack, end to end
|
||||
|
||||
private static string FindRulesPatternDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException($"Could not locate rules/ime-log-patterns walking up from {AppContext.BaseDirectory}");
|
||||
}
|
||||
|
||||
/// <summary>The shipped pack, exactly as combine.js embeds it (sorted by file name).</summary>
|
||||
private static List<ImeLogPattern> ShippedPack()
|
||||
=> Directory.GetFiles(FindRulesPatternDir(), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: ShippedPack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the CMTrace entries below are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, Now.AddSeconds(-60 + i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
[Fact]
|
||||
public async Task Shipped_pattern_captures_the_class_for_the_current_app()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, // activates the currentPhase pack (IME-EXITCODE, IME-EXITCODE-CLASS)
|
||||
CurrentAppLine, // IME-SET-CURRENT-4 → current app
|
||||
"[Win32App] lpExitCode 3010",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId);
|
||||
Assert.NotNull(pkg);
|
||||
Assert.Equal("3010", pkg!.ExitCode);
|
||||
Assert.Equal("SoftReboot", pkg.ExitCodeClass);
|
||||
Assert.NotEqual(AppInstallationState.Error, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_loop_that_ends_in_success_reports_the_last_class()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1618",
|
||||
"[Win32App] lpExitCode is defined as Retry",
|
||||
"[Win32App] lpExitCode 1618",
|
||||
"[Win32App] lpExitCode is defined as Retry",
|
||||
"[Win32App] lpExitCode 0",
|
||||
"[Win32App] lpExitCode is defined as Success");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId)!;
|
||||
Assert.Equal("0", pkg.ExitCode);
|
||||
Assert.Equal("Success", pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_class_still_drives_the_error_transition_and_carries_the_class()
|
||||
{
|
||||
// IME-EXITCODE-CLASS and IME-EXITCODE-MAPPED-FAILED both match the Failed line: the
|
||||
// class is captured and the existing error path stays intact.
|
||||
using var h = new Harness();
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
h.Tracker.PackageStates.Add(pkg);
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1603",
|
||||
"[Win32App] lpExitCode is defined as Failed");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, pkg.InstallationState);
|
||||
Assert.Equal("Failed", pkg.ExitCodeClass);
|
||||
Assert.Equal("IME-EXITCODE-MAPPED-FAILED", pkg.ErrorPatternId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_branch_and_raw_numeric_types_leave_the_class_unset()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 5",
|
||||
"[Win32App] lpExitCode is defined as 5",
|
||||
"[Win32App] lpExitCode is defined as 5, fall into default now.",
|
||||
"[Win32App] Setting enforcementState as: Error with lpExitCode: 5 without mapping");
|
||||
|
||||
await h.Pass();
|
||||
|
||||
var pkg = h.Tracker.PackageStates.GetPackage(AppId)!;
|
||||
Assert.Equal("5", pkg.ExitCode);
|
||||
Assert.Null(pkg.ExitCodeClass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Class_survives_the_state_round_trip_of_an_agent_restart()
|
||||
{
|
||||
// A SoftReboot/HardReboot app completes only after the reboot; the restarted agent
|
||||
// must still know the class when the post-reboot Success line arrives.
|
||||
using var h = new Harness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
"[Win32App] lpExitCode 1641",
|
||||
"[Win32App] lpExitCode is defined as HardReboot");
|
||||
await h.Pass();
|
||||
h.Tracker.SaveStateForTest();
|
||||
|
||||
using var restarted = h.Build();
|
||||
restarted.LoadStateForTest();
|
||||
|
||||
var pkg = restarted.GetAllKnownPackageStates().Single(p => p.Id == AppId);
|
||||
Assert.Equal("1641", pkg.ExitCode);
|
||||
Assert.Equal("HardReboot", pkg.ExitCodeClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw per-line token callbacks for the Entra user-affinity detector (2026-09-04).
|
||||
/// <c>OnUserTokenAcquired</c> fires on EVERY IME-TOKEN-SUCCESS line — independent of the
|
||||
/// phase-change dedup that swallows the second AccountSetup detection — and
|
||||
/// <c>OnTokenFailureLine</c> on every failure line; both honour the historic-replay gate.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerTokenLineCallbackTests
|
||||
{
|
||||
private static readonly DateTime Now = new DateTime(2026, 9, 4, 14, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "IME-TOKEN-SUCCESS", Category = "always", Enabled = true,
|
||||
Pattern = "^Successfully get the token",
|
||||
Action = "espPhaseDetected",
|
||||
Parameters = new Dictionary<string, string> { ["phase"] = "AccountSetup" },
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "IME-TOKEN-FAILURE", Category = "always", Enabled = true,
|
||||
Pattern = @"^Failed to get AAD token\..*?errorCode = (?<errorCode>.*?)$",
|
||||
Action = "imeTokenFailure",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static ImeLogTracker Build(TempDirectory tmp)
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: tmp.Path,
|
||||
patterns: Patterns(),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
private const string Success = "Successfully get the token with client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f";
|
||||
private const string Failure = "Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929";
|
||||
|
||||
[Fact]
|
||||
public void Every_token_success_line_invokes_callback_even_when_phase_is_deduplicated()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var tokens = new List<DateTime?>();
|
||||
tracker.OnUserTokenAcquired = ts => tokens.Add(ts);
|
||||
|
||||
// The phase-change dedup lives in the adapter (AccountSetup is emitted once);
|
||||
// the raw token callback must fire for BOTH lines regardless.
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-6));
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-2));
|
||||
|
||||
Assert.Equal(2, tokens.Count);
|
||||
Assert.Equal(Now.AddMinutes(-6), tokens[0]);
|
||||
Assert.Equal(Now.AddMinutes(-2), tokens[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_failure_line_invokes_callback_with_error_code()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var failures = new List<(string code, DateTime? ts)>();
|
||||
tracker.OnTokenFailureLine = (code, ts) => failures.Add((code, ts));
|
||||
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-5));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-4));
|
||||
|
||||
Assert.Equal(2, failures.Count);
|
||||
Assert.All(failures, f => Assert.Equal("3399548929", f.code));
|
||||
Assert.Equal(Now.AddMinutes(-5), failures[0].ts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Historic_replay_lines_do_not_invoke_either_callback()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var tokens = 0;
|
||||
var failures = 0;
|
||||
tracker.OnUserTokenAcquired = _ => tokens++;
|
||||
tracker.OnTokenFailureLine = (_, __) => failures++;
|
||||
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddDays(-3));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddDays(-3));
|
||||
|
||||
Assert.Equal(0, tokens);
|
||||
Assert.Equal(0, failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Callback_exception_does_not_break_the_drain()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
tracker.OnUserTokenAcquired = _ => throw new InvalidOperationException("boom");
|
||||
tracker.OnTokenFailureLine = (_, __) => throw new InvalidOperationException("boom");
|
||||
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-1));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -903,5 +903,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_records_packaging_counters_by_reason_and_kind_without_paths()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "small.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-1.json"), new string('x', 64));
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-2.json"), new string('y', 64));
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var service = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }));
|
||||
service.Budget = new DiagnosticsBudget { MaxSingleFileBytes = 16, MaxTotalUncompressedBytes = 1L << 30, MaxFileCount = 5000 };
|
||||
|
||||
Assert.Null(service.LastPackaging);
|
||||
var bytes = service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.True(stats!.Truncated);
|
||||
Assert.Equal(2, stats.SkippedFiles);
|
||||
Assert.Equal(2, stats.SkippedByReason["size"]);
|
||||
Assert.Single(stats.SkippedByReason);
|
||||
Assert.Equal(1, stats.ProblemsByKind["path-guard"]);
|
||||
Assert.Single(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1, "the small file must have been included");
|
||||
Assert.True(stats.IncludedBytes >= 2);
|
||||
|
||||
// The ZIP still carries the per-file list; the counters never do.
|
||||
Assert.NotNull(ReadZipEntryText(bytes, "_TRUNCATED.txt"));
|
||||
foreach (var key in stats.SkippedByReason.Keys.Concat(stats.ProblemsByKind.Keys))
|
||||
{
|
||||
Assert.DoesNotContain("\\", key);
|
||||
Assert.DoesNotContain(":", key);
|
||||
Assert.DoesNotContain("/", key);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_reports_a_clean_build_as_not_truncated_with_empty_maps()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var service = rig.Build();
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.False(stats!.Truncated);
|
||||
Assert.Equal(0, stats.SkippedFiles);
|
||||
Assert.Empty(stats.SkippedByReason);
|
||||
Assert.Empty(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_counts_a_rejected_source_folder_as_a_problem()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var outside = new TempDirectory();
|
||||
File.WriteAllText(Path.Combine(outside.Path, "snapshot.json"), "{}");
|
||||
var link = Path.Combine(rig.Tmp.Path, "StateLink");
|
||||
NtfsLinks.CreateJunction(link, outside.Path);
|
||||
|
||||
try
|
||||
{
|
||||
var service = rig.Build(sectionFolderOverrides: new Dictionary<string, string> { ["AgentState"] = link });
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
// The section is walked once per search pattern (11 patterns) but the folder
|
||||
// failed once — the counter says so.
|
||||
Assert.Equal(1, service.LastPackaging!.ProblemsByKind["folder-rejected"]);
|
||||
Assert.False(service.LastPackaging.Truncated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -243,12 +243,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeHResult_KnownAndUnknown()
|
||||
public void ResolveHResultSymbol_ReadsTheSharedCatalog()
|
||||
{
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", WindowsUpdateTracker.DecodeHResult(0x80240022));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", WindowsUpdateTracker.DecodeHResult(0x800F0922));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.DecodeHResult(0x00000000));
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.DecodeHResult(0x8888DEAD));
|
||||
Assert.Equal("WU_E_ALL_UPDATES_FAILED", WindowsUpdateTracker.ResolveHResultSymbol(0x80240022));
|
||||
Assert.Equal("CBS_E_INSTALLERS_FAILED", WindowsUpdateTracker.ResolveHResultSymbol(0x800F0922));
|
||||
Assert.Equal("ERROR_TIMEOUT", WindowsUpdateTracker.ResolveHResultSymbol(0x800705B4));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.ResolveHResultSymbol(0x00000000));
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x8888DEAD));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHResultSymbol_FallsBackWhenTheCatalogHasNoSymbolOrThrows()
|
||||
{
|
||||
// An entry without a symbol (IME/Intune paraphrases) still yields the sentinel.
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x87D1041C));
|
||||
// A catalog load failure is decoration lost, never an event lost.
|
||||
Assert.Equal("WU_E_UNKNOWN", WindowsUpdateTracker.ResolveHResultSymbol(0x80240022, _ => throw new InvalidOperationException("catalog")));
|
||||
Assert.Equal("S_OK", WindowsUpdateTracker.ResolveHResultSymbol(0, _ => throw new InvalidOperationException("catalog")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+25
@@ -56,6 +56,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
|
||||
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_profile_prefixes_are_all_accepted_by_the_loader()
|
||||
{
|
||||
// The loader drops entries outside AppData\Local / AppData\Roaming (the hard block
|
||||
// would never release them anyway). A dropped entry is invisible at runtime — it
|
||||
// just never matches — so a typo in the JSON must fail here instead.
|
||||
var json = LoadEmbeddedGuardrails()["userProfileFilePrefixes"]?.ToObject<string[]>()
|
||||
?? new string[0];
|
||||
|
||||
Assert.Equal(
|
||||
json.Select(p => p.Trim().TrimEnd('\\')).OrderBy(p => p),
|
||||
GatherRuleGuards.AllowedUserProfileFilePrefixes.OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_profile_prefixes_stay_under_the_hard_blocks_exception()
|
||||
{
|
||||
Assert.All(
|
||||
GatherRuleGuards.AllowedUserProfileFilePrefixes,
|
||||
prefix => Assert.Contains(
|
||||
GatherRuleGuards.AllowedUserProfileSubdirs,
|
||||
subdir => prefix.StartsWith(subdir, System.StringComparison.OrdinalIgnoreCase) &&
|
||||
(prefix.Length == subdir.Length || prefix[subdir.Length] == '\\')));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_event_log_channels_match_code_constants()
|
||||
{
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
@@ -42,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// items must be a whiteglove_resumed event.
|
||||
Assert.DoesNotContain(
|
||||
rig.Uploader.Received.SelectMany(b => b),
|
||||
item => item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item => item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""));
|
||||
}
|
||||
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var resumedEvents = rig.Uploader.Received
|
||||
.SelectMany(b => b)
|
||||
.Where(item =>
|
||||
item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""))
|
||||
.ToList();
|
||||
|
||||
@@ -15,6 +15,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+10
-1
@@ -282,8 +282,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
errorPatternId: null,
|
||||
errorDetail: null,
|
||||
errorCode: null,
|
||||
exitCode: null,
|
||||
exitCode: "0",
|
||||
hresultFromWin32: null,
|
||||
exitCodeClass: "Success",
|
||||
appVersion: "11.2.1787.0",
|
||||
appType: "WinGet",
|
||||
attemptNumber: 1,
|
||||
@@ -293,6 +294,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal("app-xyz", info.Payload!["appId"]);
|
||||
// Installer outcome surfaces on the terminal event even when the app succeeded: the
|
||||
// return-code class is the only evidence of a SoftReboot/HardReboot mapping and the
|
||||
// number says which code was mapped (IME-EXITCODE-CLASS, ANALYZE-APP-018).
|
||||
Assert.Equal("Success", info.Payload["exitCodeClass"]);
|
||||
Assert.Equal("0", info.Payload["exitCode"]);
|
||||
Assert.Equal("Company Portal", info.Payload["appName"]);
|
||||
Assert.Equal("Installed", info.Payload["state"]);
|
||||
Assert.Equal("Install", info.Payload["intent"]);
|
||||
@@ -331,6 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
errorCode: "60001",
|
||||
exitCode: "60001",
|
||||
hresultFromWin32: "-2146964895",
|
||||
exitCodeClass: "Failed",
|
||||
attemptNumber: 1,
|
||||
detectionResult: "NotDetected");
|
||||
|
||||
@@ -340,6 +347,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("true", info.Payload!["isError"]);
|
||||
Assert.Equal("IME-ERROR-UNMAPPED-EXIT", info.Payload["errorPatternId"]);
|
||||
Assert.Equal("60001", info.Payload["errorCode"]);
|
||||
Assert.Equal("60001", info.Payload["exitCode"]);
|
||||
Assert.Equal("Failed", info.Payload["exitCodeClass"]);
|
||||
Assert.Equal("-2146964895", info.Payload["hresultFromWin32"]);
|
||||
Assert.Equal(EventSeverity.Error.ToString(), info.Payload[SignalPayloadKeys.Severity]);
|
||||
// Codex review follow-up (P1): an unrelated ErrorPatternId (here a regular
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+81
@@ -1701,5 +1701,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
rig.PromotionCalls[0].failureType);
|
||||
Assert.Null(rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters on the diag events
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_uploaded_carries_packaging_counters_when_the_packager_reported_them()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = "diag-blob.zip",
|
||||
Packaging = new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = 412,
|
||||
IncludedBytes = 123456,
|
||||
SkippedFiles = 3,
|
||||
SkippedByReason = new Dictionary<string, int> { ["size"] = 2, ["total"] = 1 },
|
||||
ProblemsByKind = new Dictionary<string, int> { ["copy"] = 1 },
|
||||
},
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.Equal(true, uploaded!["truncated"]);
|
||||
Assert.Equal(412, uploaded["includedFiles"]);
|
||||
Assert.Equal(123456L, uploaded["includedBytes"]);
|
||||
Assert.Equal(3, uploaded["skippedFiles"]);
|
||||
var byReason = Assert.IsType<Dictionary<string, int>>(uploaded["skippedByReason"]);
|
||||
Assert.Equal(2, byReason["size"]);
|
||||
Assert.Equal(1, byReason["total"]);
|
||||
var problems = Assert.IsType<Dictionary<string, int>>(uploaded["problemsByKind"]);
|
||||
Assert.Equal(1, problems["copy"]);
|
||||
// Counters only — no key or value carries a file path.
|
||||
foreach (var kv in uploaded)
|
||||
Assert.False(kv.Value is string s && (s.Contains("\\") || s.Contains("/")) && kv.Key != "sasUrlPrefix",
|
||||
$"payload key {kv.Key} looks like a path: {kv.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_upload_failed_after_a_build_carries_the_counters_too()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
ErrorCode = "upload_5xx",
|
||||
Packaging = new DiagnosticsPackagingStats { IncludedFiles = 40, IncludedBytes = 1000 },
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
var failed = rig.DataOf("diagnostics_upload_failed");
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("upload_5xx", (string)failed!["errorCode"]);
|
||||
Assert.Equal(false, failed["truncated"]);
|
||||
Assert.Equal(40, failed["includedFiles"]);
|
||||
Assert.Empty(Assert.IsType<Dictionary<string, int>>(failed["skippedByReason"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_events_omit_the_counters_when_no_package_was_built()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.False(uploaded!.ContainsKey("truncated"));
|
||||
Assert.False(uploaded.ContainsKey("skippedByReason"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent → backend wire contract of <c>POST /api/agent/telemetry</c>, proven from the
|
||||
/// WRITER side. <see cref="TelemetryItemDto"/> (Shared) is the one class both ends serialise;
|
||||
/// <see cref="TelemetryItem.ToWire"/> is the only mapping onto it.
|
||||
/// <list type="bullet">
|
||||
/// <item>Round trip + reflection: every DTO property is populated by <c>ToWire()</c> and
|
||||
/// survives Newtonsoft defaults — a DTO field added without a mapping fails here.</item>
|
||||
/// <item>Frozen body: the real batch serialiser's output for one item per
|
||||
/// <see cref="TelemetryItemKind"/> is pinned under <c>tests/fixtures/telemetry-wire/</c>.
|
||||
/// The backend's <c>TelemetryWireFixtureTests</c> reads the same files and demands zero
|
||||
/// rejections, so a rename on either side breaks a test until both sides and the fixture
|
||||
/// agree. Regenerate with <c>AM_WRITE_WIRE_FIXTURES=1</c>.</item>
|
||||
/// <item>Poison body: the exact 422 shape the backend writes (pinned there by
|
||||
/// <c>TelemetryWireFixtureTests</c>) is parsed as an item-level poison signal here.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class TelemetryWireContractTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private const string FixtureFile = "agent-v2.json";
|
||||
|
||||
private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TelemetryItem FullyPopulatedItem() =>
|
||||
new TelemetryItem(
|
||||
kind: TelemetryItemKind.DecisionTransition,
|
||||
partitionKey: $"{TenantId}_{SessionId}",
|
||||
rowKey: "0000000007",
|
||||
telemetryItemId: 42,
|
||||
sessionTraceOrdinal: 7,
|
||||
payloadJson: "{\"StepIndex\":7}",
|
||||
requiresImmediateFlush: true,
|
||||
enqueuedAtUtc: At,
|
||||
retryCount: 3);
|
||||
|
||||
// ============================================================ ToWire mapping
|
||||
|
||||
[Fact]
|
||||
public void ToWire_round_trips_every_property_through_Newtonsoft_defaults()
|
||||
{
|
||||
var item = FullyPopulatedItem();
|
||||
|
||||
var json = JsonConvert.SerializeObject(item.ToWire());
|
||||
var dto = JsonConvert.DeserializeObject<TelemetryItemDto>(json)!;
|
||||
|
||||
Assert.Equal("DecisionTransition", dto.Kind);
|
||||
Assert.Equal(item.PartitionKey, dto.PartitionKey);
|
||||
Assert.Equal(item.RowKey, dto.RowKey);
|
||||
Assert.Equal(item.TelemetryItemId, dto.TelemetryItemId);
|
||||
Assert.Equal(item.SessionTraceOrdinal, dto.SessionTraceOrdinal);
|
||||
Assert.Equal(item.PayloadJson, dto.PayloadJson);
|
||||
Assert.Equal(item.RequiresImmediateFlush, dto.RequiresImmediateFlush);
|
||||
Assert.Equal(item.EnqueuedAtUtc, dto.EnqueuedAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, dto.EnqueuedAtUtc.Kind);
|
||||
Assert.Equal(item.RetryCount, dto.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWire_populates_every_TelemetryItemDto_property()
|
||||
{
|
||||
// A DTO property that ToWire() does not set keeps its default — this catches a field
|
||||
// added to the wire type without a mapping (the backend would read the default forever).
|
||||
var dto = FullyPopulatedItem().ToWire();
|
||||
|
||||
var unset = typeof(TelemetryItemDto)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => IsDefault(p.GetValue(dto), p.PropertyType))
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.True(unset.Count == 0,
|
||||
"TelemetryItem.ToWire() leaves these TelemetryItemDto properties at their default: " + string.Join(", ", unset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelemetryItemDto_properties_mirror_TelemetryItem()
|
||||
{
|
||||
var itemProps = typeof(TelemetryItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.ToDictionary(p => p.Name, p => p.PropertyType);
|
||||
|
||||
foreach (var dtoProp in typeof(TelemetryItemDto).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
Assert.True(itemProps.TryGetValue(dtoProp.Name, out var itemType),
|
||||
$"TelemetryItemDto.{dtoProp.Name} has no counterpart on TelemetryItem");
|
||||
var expected = dtoProp.Name == nameof(TelemetryItemDto.Kind) ? typeof(TelemetryItemKind) : dtoProp.PropertyType;
|
||||
Assert.True(itemType == expected,
|
||||
$"TelemetryItemDto.{dtoProp.Name} is {dtoProp.PropertyType.Name}, TelemetryItem.{dtoProp.Name} is {itemType.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_kind_serialises_by_name_on_the_wire_and_on_the_spool_line()
|
||||
{
|
||||
foreach (var kind in TelemetryItemKinds.All)
|
||||
{
|
||||
var item = new TelemetryItem(kind, "T_S", "rk", 1, 1, "{}", false, At);
|
||||
|
||||
var wire = JObject.Parse(JsonConvert.SerializeObject(item.ToWire()));
|
||||
Assert.Equal(kind.ToString(), (string?)wire["Kind"]);
|
||||
|
||||
// The spool line (TelemetryItemSerializer) has always written the NAME too; the enum's
|
||||
// move to Shared must not turn it into an ordinal (a crash-restart rehydrates it).
|
||||
var spool = JObject.Parse(TelemetryItemSerializer.Serialize(item));
|
||||
Assert.Equal(kind.ToString(), (string?)spool["Kind"]);
|
||||
Assert.Equal(kind, TelemetryItemSerializer.Deserialize(spool.ToString(Formatting.None)).Kind);
|
||||
|
||||
Assert.True(TelemetryItemKinds.TryParse(kind.ToString(), out var parsed));
|
||||
Assert.Equal(kind, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Frozen wire body
|
||||
|
||||
[Fact]
|
||||
public void Frozen_wire_body_matches_the_current_serializer()
|
||||
{
|
||||
var items = OneItemPerKind();
|
||||
Assert.Equal(TelemetryItemKinds.All.OrderBy(k => k), items.Select(i => i.Kind).Distinct().OrderBy(k => k));
|
||||
|
||||
var body = BackendTelemetryUploader.SerializeBatch(items);
|
||||
var path = Path.Combine(RepoPaths.Fixtures("telemetry-wire"), FixtureFile);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("AM_WRITE_WIRE_FIXTURES") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, body + "\n", new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(path), "Missing wire fixture " + path + " — run this test with AM_WRITE_WIRE_FIXTURES=1 to create it.");
|
||||
var frozen = File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||
Assert.True(string.Equals(frozen, body, StringComparison.Ordinal),
|
||||
"The agent's telemetry wire body changed. If the change is intended, update the backend ingest " +
|
||||
"(TelemetryWireFixtureTests must still dispatch every frozen body) and regenerate with " +
|
||||
"AM_WRITE_WIRE_FIXTURES=1.\nExpected: " + frozen + "\nActual: " + body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One item per kind through the REAL emitters (payload shapes are theirs, not hand-typed),
|
||||
/// re-based onto fixed transport fields so the body is byte-stable.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TelemetryItem> OneItemPerKind()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
|
||||
|
||||
new TelemetryEventEmitter(transport, counter, SessionId, TenantId).Emit(new EnrollmentEvent
|
||||
{
|
||||
EventId = "00000000-0000-4000-8000-000000000001",
|
||||
EventType = Constants.EventTypes.AgentStarted,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Agent",
|
||||
Timestamp = At,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "Agent started",
|
||||
Data = new Dictionary<string, object> { ["agentVersion"] = "2.0.0" },
|
||||
});
|
||||
|
||||
new TelemetrySignalEmitter(transport, SessionId, TenantId).Emit(new DecisionSignal(
|
||||
sessionSignalOrdinal: 1,
|
||||
sessionTraceOrdinal: 1,
|
||||
kind: DecisionSignalKind.SessionStarted,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: At,
|
||||
sourceOrigin: "Fixture",
|
||||
evidence: new Evidence(EvidenceKind.Raw, "raw-1", "session start")));
|
||||
|
||||
new TelemetryTransitionEmitter(transport, SessionId, TenantId).Emit(new DecisionTransition(
|
||||
stepIndex: 1,
|
||||
sessionTraceOrdinal: 2,
|
||||
signalOrdinalRef: 1,
|
||||
occurredAtUtc: At,
|
||||
trigger: "SessionStarted",
|
||||
fromStage: SessionStage.Unknown,
|
||||
toStage: SessionStage.AwaitingEspPhaseChange,
|
||||
taken: true,
|
||||
deadEndReason: null,
|
||||
reducerVersion: "1.0.0"));
|
||||
|
||||
return transport.Enqueued
|
||||
.Select(e => new TelemetryItem(e.Kind, e.PartitionKey, e.RowKey, e.TelemetryItemId, e.SessionTraceOrdinal,
|
||||
e.PayloadJson, e.RequiresImmediateFlush, At))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ============================================================ Poison body
|
||||
|
||||
/// <summary>
|
||||
/// The exact 422 body the backend writes for rejected items (pinned byte-for-byte in the
|
||||
/// backend's TelemetryWireFixtureTests). camelCase because the backend serialises with
|
||||
/// its wire options; the agent's parser accepts both casings.
|
||||
/// </summary>
|
||||
internal const string BackendPoisonBody =
|
||||
"{\"error\":\"2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.\"," +
|
||||
"\"code\":\"TelemetryItemsRejected\",\"correlationId\":\"cid-1\",\"poison\":true,\"rejectedRowKeys\":[\"rk-1\",\"rk-3\"]," +
|
||||
"\"reason\":\"unknown_kind=1;unparseable=1\",\"received\":3,\"rejected\":2}";
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_422_poison_body_is_parsed_as_item_level_poison()
|
||||
{
|
||||
using var handler = new RecordingHttpMessageHandler().QueueStatus((HttpStatusCode)422, BackendPoisonBody);
|
||||
using var http = new HttpClient(handler);
|
||||
var sut = new BackendTelemetryUploader(http, baseUrl: "https://backend.example.invalid", tenantId: TenantId, agentVersion: "2.0.0");
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { FullyPopulatedItem() }, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsPoison);
|
||||
Assert.Equal(new[] { "rk-1", "rk-3" }, result.PoisonRowKeys);
|
||||
Assert.Equal("unknown_kind=1;unparseable=1", result.ErrorReason);
|
||||
}
|
||||
|
||||
private static bool IsDefault(object? value, Type type)
|
||||
{
|
||||
if (value == null) return true;
|
||||
if (type == typeof(string)) return ((string)value).Length == 0;
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsValueType && value.Equals(Activator.CreateInstance(underlying));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Installer result codes (captured from lpExitCode / hResultFromWin32 log lines)
|
||||
public string ExitCode { get; private set; }
|
||||
public string HResultFromWin32 { get; private set; }
|
||||
/// <summary>
|
||||
/// Admin-defined return-code class of the last installer exit code: Success, SoftReboot,
|
||||
/// HardReboot, Retry or Failed (IME's AppReturnCodeType, captured from the
|
||||
/// "lpExitCode is defined as X" line by IME-EXITCODE-CLASS). Last value wins, so a Retry
|
||||
/// loop that ends in Success reports Success.
|
||||
/// </summary>
|
||||
public string ExitCodeClass { get; private set; }
|
||||
|
||||
// App metadata (captured from IME log patterns — used by App Dashboard / reports)
|
||||
/// <summary>Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.</summary>
|
||||
@@ -167,6 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
int? progressPercent, long bytesDownloaded, long bytesTotal,
|
||||
string errorPatternId = null, string errorDetail = null, string errorCode = null,
|
||||
string exitCode = null, string hresultFromWin32 = null,
|
||||
string exitCodeClass = null,
|
||||
long doFileSize = 0, long doTotalBytesDownloaded = 0,
|
||||
long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
|
||||
long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
|
||||
@@ -195,6 +203,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ErrorCode = errorCode,
|
||||
ExitCode = exitCode,
|
||||
HResultFromWin32 = hresultFromWin32,
|
||||
ExitCodeClass = exitCodeClass,
|
||||
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
|
||||
DoFileSize = doFileSize,
|
||||
DoTotalBytesDownloaded = doTotalBytesDownloaded,
|
||||
@@ -302,6 +311,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HResultFromWin32 = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the return-code class (Success/SoftReboot/HardReboot/Retry/Failed) IME assigned
|
||||
/// to the last exit code. Last value wins; empty values are ignored.
|
||||
/// </summary>
|
||||
public bool UpdateExitCodeClass(string exitCodeClass)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exitCodeClass)) return false;
|
||||
var trimmed = exitCodeClass.Trim();
|
||||
if (ExitCodeClass == trimmed) return false;
|
||||
ExitCodeClass = trimmed;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the product version detected for this app (e.g. "11.2.1787.0").
|
||||
/// Only applies non-empty values so a later detection without version cannot wipe it.
|
||||
@@ -547,6 +569,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
data["exitCode"] = ExitCode;
|
||||
if (!string.IsNullOrEmpty(HResultFromWin32))
|
||||
data["hresultFromWin32"] = HResultFromWin32;
|
||||
if (!string.IsNullOrEmpty(ExitCodeClass))
|
||||
data["exitCodeClass"] = ExitCodeClass;
|
||||
|
||||
// App metadata fields (emitted whenever available so ingest can merge them).
|
||||
if (!string.IsNullOrEmpty(AppVersion))
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// A writer era: the stretch of log written by ONE process lifetime, i.e. under one
|
||||
/// timezone belief. For the IME service family (IntuneManagementExtension*.log) an era
|
||||
/// starts at an "EMS Agent Started" entry and may continue across a log rotation into the
|
||||
/// next file; for AgentExecutor.log every script execution is its own short-lived process
|
||||
/// and therefore its own era. An era carries an offset only when an anchor inside it was
|
||||
/// matched against an instant the agent knows in UTC (<see cref="ImeLogEraPreScan"/>).
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEra
|
||||
{
|
||||
/// <summary>File segments (file name → [start, end) byte range) that belong to this era, in write order.</summary>
|
||||
public List<CmTraceEraSegment> Segments { get; } = new List<CmTraceEraSegment>();
|
||||
|
||||
/// <summary>Local time of the era's first entry as written (null for a leading era whose start predates the scanned region).</summary>
|
||||
public DateTime? StartLocal { get; set; }
|
||||
|
||||
/// <summary>Local time of the next era's first entry (null for the last era).</summary>
|
||||
public DateTime? EndLocal { get; set; }
|
||||
|
||||
/// <summary>The writer's UTC offset (local = UTC + offset) when anchored.</summary>
|
||||
public TimeSpan? Offset { get; set; }
|
||||
|
||||
/// <summary>What established <see cref="Offset"/> ("bootstrap-policy-result", "bootstrap-stdout", …).</summary>
|
||||
public string? AnchorKind { get; set; }
|
||||
|
||||
public bool IsAnchored => Offset.HasValue;
|
||||
}
|
||||
|
||||
internal sealed class CmTraceEraSegment
|
||||
{
|
||||
public CmTraceEraSegment(string fileName, long startOffset, long endOffset)
|
||||
{
|
||||
FileName = fileName;
|
||||
StartOffset = startOffset;
|
||||
EndOffset = endOffset;
|
||||
}
|
||||
|
||||
public string FileName { get; }
|
||||
public long StartOffset { get; }
|
||||
/// <summary><c>long.MaxValue</c> while the era is still open in this file.</summary>
|
||||
public long EndOffset { get; set; }
|
||||
|
||||
public bool Contains(string fileName, long offset) =>
|
||||
string.Equals(FileName, fileName, StringComparison.OrdinalIgnoreCase)
|
||||
&& offset >= StartOffset && offset < EndOffset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Era lookup for backlog lines (2026-09-04, session a7140f98). Two lookups:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TryResolveByOffset"/> — the line's own file and byte offset
|
||||
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
|
||||
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
|
||||
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
|
||||
/// decompiled build). Within one process every file shares the zone belief, so a local
|
||||
/// timestamp places the line in the service era whose local range contains it. Disabled
|
||||
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
|
||||
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
|
||||
/// </list>
|
||||
/// Everything not resolvable here stays on the marked reader-zone fallback; a partially
|
||||
/// corrected era is never produced because an anchor applies to a whole era or not at all.
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEraTable
|
||||
{
|
||||
private readonly List<CmTraceEra> _offsetEras = new List<CmTraceEra>();
|
||||
private readonly List<CmTraceEra> _serviceEras = new List<CmTraceEra>();
|
||||
private bool _transferAmbiguous;
|
||||
|
||||
public IReadOnlyList<CmTraceEra> ServiceEras => _serviceEras;
|
||||
public bool TransferAmbiguous => _transferAmbiguous;
|
||||
public int AnchoredEraCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var e in _offsetEras) if (e.IsAnchored) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register an era addressable by (file, offset). Service eras also take part in the local-time transfer.</summary>
|
||||
public void Add(CmTraceEra era, bool isServiceEra)
|
||||
{
|
||||
_offsetEras.Add(era);
|
||||
if (isServiceEra) _serviceEras.Add(era);
|
||||
}
|
||||
|
||||
/// <summary>Call once after all service eras are added: validates the local ranges used by the transfer.</summary>
|
||||
public void Seal()
|
||||
{
|
||||
_transferAmbiguous = false;
|
||||
DateTime? previousStart = null;
|
||||
foreach (var era in _serviceEras)
|
||||
{
|
||||
if (!era.StartLocal.HasValue) continue;
|
||||
if (previousStart.HasValue && era.StartLocal.Value <= previousStart.Value)
|
||||
{
|
||||
_transferAmbiguous = true;
|
||||
break;
|
||||
}
|
||||
previousStart = era.StartLocal;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveByOffset(string fileName, long offset, out TimeSpan writerOffset, out string anchorKind)
|
||||
{
|
||||
writerOffset = TimeSpan.Zero;
|
||||
anchorKind = string.Empty;
|
||||
if (string.IsNullOrEmpty(fileName) || offset < 0) return false;
|
||||
|
||||
foreach (var era in _offsetEras)
|
||||
{
|
||||
if (!era.IsAnchored) continue;
|
||||
foreach (var seg in era.Segments)
|
||||
{
|
||||
if (seg.Contains(fileName, offset))
|
||||
{
|
||||
writerOffset = era.Offset!.Value;
|
||||
anchorKind = era.AnchorKind ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryResolveByLocalTime(DateTime localTimestamp, out TimeSpan writerOffset, out string anchorKind)
|
||||
{
|
||||
writerOffset = TimeSpan.Zero;
|
||||
anchorKind = string.Empty;
|
||||
if (_transferAmbiguous) return false;
|
||||
|
||||
var local = DateTime.SpecifyKind(localTimestamp, DateTimeKind.Unspecified);
|
||||
foreach (var era in _serviceEras)
|
||||
{
|
||||
if (!era.IsAnchored) continue;
|
||||
// A leading era with unknown start covers everything before its end.
|
||||
var afterStart = !era.StartLocal.HasValue || local >= era.StartLocal.Value;
|
||||
var beforeEnd = !era.EndLocal.HasValue || local < era.EndLocal.Value;
|
||||
if (afterStart && beforeEnd)
|
||||
{
|
||||
writerOffset = era.Offset!.Value;
|
||||
anchorKind = era.AnchorKind + "/service-era-transfer";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>.
|
||||
/// </summary>
|
||||
LineAnchored = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A backlog line resolved through its writer ERA: the stretch of log written by one
|
||||
/// process lifetime, anchored by an entry inside that era whose UTC instant the agent
|
||||
/// knows exactly (the install marker vs. the IME's own record of the bootstrap script's
|
||||
/// result — a few hundred milliseconds apart). An anchor applies to a whole era or not
|
||||
/// at all, never across an "EMS Agent Started" boundary. See <c>ImeLogEraPreScan</c>
|
||||
/// (2026-09-04, session a7140f98).
|
||||
/// </summary>
|
||||
EraAnchored = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <see cref="CmTraceEraTable"/> for the backlog the tracker is about to read
|
||||
/// (2026-09-04, session a7140f98: the IME service wrote the whole device-ESP era under the
|
||||
/// OOBE default zone, Windows moved the zone before the agent started, and the restart
|
||||
/// replay put every pre-reboot line three hours early — a 20-minute DeviceSetup became a
|
||||
/// "blocking app timeout").
|
||||
/// <para>
|
||||
/// Backlog lines can never self-anchor (their age would round onto the offset grid), so an
|
||||
/// era needs an entry whose UTC instant is known independently. The one such entry every
|
||||
/// platform-script bootstrap leaves behind is the bootstrap script's own execution: the
|
||||
/// install marker (<c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, UTC) is written
|
||||
/// milliseconds before the script exits, and the IME logs that exit twice — AgentExecutor.log
|
||||
/// records the script's stdout ("Bootstrap Completed Successfully"), and the service log
|
||||
/// records "[PowerShell] … Policy id = <bootstrap>, policy result = …". Field measurement
|
||||
/// (4 sessions, 2026-09-04): 0.13–0.28 s between marker and stdout record, 0.02–0.08 s more to
|
||||
/// the service record — against a 15-minute grid with a 2-minute residual guard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rules that keep this sound: only the FIRST bootstrap record of each file is offered (a
|
||||
/// SKIP re-run minutes later could round onto the grid); the residual guard is
|
||||
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>; an anchor applies to the whole
|
||||
/// era between two "EMS Agent Started" entries (service) or one script execution
|
||||
/// (AgentExecutor) and never beyond; nothing is anchored without a marker (MSI bootstrap,
|
||||
/// replay tooling). Everything else stays on the marked reader-zone fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImeLogEraPreScan
|
||||
{
|
||||
internal const string AnchorKindBootstrapStdout = "bootstrap-stdout";
|
||||
internal const string AnchorKindBootstrapPolicyResult = "bootstrap-policy-result";
|
||||
|
||||
private const string ServiceStartedPrefix = "EMS Agent Started";
|
||||
private const string ExecutorScriptStartPrefix = "Adding argument powershell with value ";
|
||||
private const string ExecutorOutputPrefix = "write output done.";
|
||||
private const string BootstrapInstallRunMarker = "Bootstrap Completed Successfully";
|
||||
private const string BootstrapInstallRunMarkerAlt = "Agent install mode completed successfully";
|
||||
|
||||
private static readonly Regex ExecutorScriptPolicyId = new Regex(
|
||||
@"Policies\\Scripts\\[a-z0-9\-]+_(?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.ps1",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly Regex ServicePolicyResult = new Regex(
|
||||
@"^\[PowerShell\] User Id = [a-f0-9\-]+, Policy id = (?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}), policy result = ",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
|
||||
private const int MaxEntryLines = 100;
|
||||
|
||||
public sealed class ScanInput
|
||||
{
|
||||
public ScanInput(string filePath, long startOffset)
|
||||
{
|
||||
FilePath = filePath;
|
||||
StartOffset = startOffset;
|
||||
}
|
||||
|
||||
public string FilePath { get; }
|
||||
public long StartOffset { get; }
|
||||
}
|
||||
|
||||
public static bool IsServiceLogFile(string fileName) =>
|
||||
fileName.StartsWith("IntuneManagementExtension", StringComparison.OrdinalIgnoreCase)
|
||||
|| fileName.StartsWith("_IntuneManagementExtension", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsExecutorLogFile(string fileName) =>
|
||||
fileName.StartsWith("AgentExecutor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Files written in-process by the IME service without an era marker of their own (transfer by local time).</summary>
|
||||
public static bool IsServiceHostedFile(string fileName) =>
|
||||
fileName.StartsWith("AppWorkload", StringComparison.OrdinalIgnoreCase)
|
||||
|| fileName.StartsWith("AppActionProcessor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Scan the given files (sort order = write order; archived before current) from their
|
||||
/// start offsets to EOF and return the era table. Never throws; a file that cannot be
|
||||
/// read contributes no eras.
|
||||
/// </summary>
|
||||
public static CmTraceEraTable Build(
|
||||
IReadOnlyList<ScanInput> files,
|
||||
DateTime? deployedUtc,
|
||||
int maxEntryBytes,
|
||||
AgentLogger? logger)
|
||||
{
|
||||
var table = new CmTraceEraTable();
|
||||
if (!deployedUtc.HasValue)
|
||||
{
|
||||
logger?.Info("ImeLogEraPreScan: no Deployed marker — backlog eras stay on the reader-zone fallback");
|
||||
return table;
|
||||
}
|
||||
|
||||
string? bootstrapPolicyId = null;
|
||||
|
||||
// AgentExecutor first: it is where the bootstrap policy id becomes known.
|
||||
foreach (var input in files)
|
||||
{
|
||||
if (!IsExecutorLogFile(Path.GetFileName(input.FilePath))) continue;
|
||||
try { ScanExecutorFile(input, deployedUtc.Value, maxEntryBytes, table, ref bootstrapPolicyId, logger); }
|
||||
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: {Path.GetFileName(input.FilePath)} skipped: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var serviceFiles = new List<ScanInput>();
|
||||
foreach (var input in files)
|
||||
if (IsServiceLogFile(Path.GetFileName(input.FilePath))) serviceFiles.Add(input);
|
||||
|
||||
try { ScanServiceFiles(serviceFiles, deployedUtc.Value, maxEntryBytes, table, bootstrapPolicyId, logger); }
|
||||
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: service scan aborted: {ex.Message}"); }
|
||||
|
||||
table.Seal();
|
||||
|
||||
logger?.Info(
|
||||
$"ImeLogEraPreScan: {table.AnchoredEraCount} anchored era(s), {table.ServiceEras.Count} service era(s), " +
|
||||
$"bootstrapPolicyId={(bootstrapPolicyId ?? "(unknown)")}, transfer={(table.TransferAmbiguous ? "ambiguous-disabled" : "enabled")}");
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void ScanExecutorFile(
|
||||
ScanInput input, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
|
||||
ref string? bootstrapPolicyId, AgentLogger? logger)
|
||||
{
|
||||
var fileName = Path.GetFileName(input.FilePath);
|
||||
CmTraceEra? current = null;
|
||||
string? currentPolicyId = null;
|
||||
var anchored = false;
|
||||
|
||||
foreach (var e in ReadEntries(input, maxEntryBytes))
|
||||
{
|
||||
if (e.Message.StartsWith(ExecutorScriptStartPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (current != null) current.Segments[0].EndOffset = e.Offset;
|
||||
current = new CmTraceEra();
|
||||
current.Segments.Add(new CmTraceEraSegment(fileName, e.Offset, long.MaxValue));
|
||||
current.StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null;
|
||||
table.Add(current, isServiceEra: false);
|
||||
var m = ExecutorScriptPolicyId.Match(e.Message);
|
||||
currentPolicyId = m.Success ? m.Groups["id"].Value : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anchored || current == null || !e.HasTimestamp) continue;
|
||||
if (!e.Message.StartsWith(ExecutorOutputPrefix, StringComparison.Ordinal)) continue;
|
||||
if (e.Message.IndexOf(BootstrapInstallRunMarker, StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& e.Message.IndexOf(BootstrapInstallRunMarkerAlt, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
|
||||
// First bootstrap install record of this file, offered once — accepted or not.
|
||||
anchored = true;
|
||||
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
|
||||
{
|
||||
current.Offset = offset;
|
||||
current.AnchorKind = AnchorKindBootstrapStdout;
|
||||
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap execution anchored at {offset} (stdout local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, policy={currentPolicyId ?? "?"})");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap stdout record rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
|
||||
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanServiceFiles(
|
||||
IReadOnlyList<ScanInput> files, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
|
||||
string? bootstrapPolicyId, AgentLogger? logger)
|
||||
{
|
||||
if (files.Count == 0) return;
|
||||
|
||||
// The leading era: whatever process was writing when the scanned region begins.
|
||||
var current = new CmTraceEra();
|
||||
table.Add(current, isServiceEra: true);
|
||||
var resultOffered = false;
|
||||
|
||||
foreach (var input in files)
|
||||
{
|
||||
var fileName = Path.GetFileName(input.FilePath);
|
||||
var segment = new CmTraceEraSegment(fileName, input.StartOffset, long.MaxValue);
|
||||
current.Segments.Add(segment);
|
||||
|
||||
foreach (var e in ReadEntries(input, maxEntryBytes))
|
||||
{
|
||||
if (e.Message.StartsWith(ServiceStartedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
segment.EndOffset = e.Offset;
|
||||
var next = new CmTraceEra { StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null };
|
||||
current.EndLocal = next.StartLocal;
|
||||
segment = new CmTraceEraSegment(fileName, e.Offset, long.MaxValue);
|
||||
next.Segments.Add(segment);
|
||||
table.Add(next, isServiceEra: true);
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultOffered || bootstrapPolicyId == null || !e.HasTimestamp) continue;
|
||||
if (!e.Message.StartsWith("[PowerShell] User Id = ", StringComparison.Ordinal)) continue;
|
||||
var m = ServicePolicyResult.Match(e.Message);
|
||||
if (!m.Success || !string.Equals(m.Groups["id"].Value, bootstrapPolicyId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
// First result record for the bootstrap policy, offered once.
|
||||
resultOffered = true;
|
||||
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
|
||||
{
|
||||
current.Offset = offset;
|
||||
current.AnchorKind = AnchorKindBootstrapPolicyResult;
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} service era anchored at {offset} (policy result local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff})");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap policy result rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
|
||||
}
|
||||
}
|
||||
|
||||
// The era continues into the next (newer) file: close this file's segment at EOF.
|
||||
try { segment.EndOffset = new FileInfo(input.FilePath).Length; } catch { segment.EndOffset = long.MaxValue; }
|
||||
}
|
||||
|
||||
// The last era stays open in its last file.
|
||||
var last = current.Segments[current.Segments.Count - 1];
|
||||
last.EndOffset = long.MaxValue;
|
||||
}
|
||||
|
||||
private sealed class ScannedEntry
|
||||
{
|
||||
public long Offset;
|
||||
public bool HasTimestamp;
|
||||
public DateTime LocalTimestamp;
|
||||
public string Message = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate CMTrace entries with their file offsets. Same multiline assembly as the
|
||||
/// tracker's read loop (an AgentExecutor stdout record spans many lines; the closing tag
|
||||
/// carries the time); oversized or over-long entries are dropped, never partially used.
|
||||
/// </summary>
|
||||
private static IEnumerable<ScannedEntry> ReadEntries(ScanInput input, int maxEntryBytes)
|
||||
{
|
||||
using (var stream = new FileStream(input.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
if (input.StartOffset > 0 && input.StartOffset < stream.Length)
|
||||
stream.Seek(input.StartOffset, SeekOrigin.Begin);
|
||||
else if (input.StartOffset >= stream.Length)
|
||||
yield break;
|
||||
|
||||
var reader = new BoundedLineReader(stream, maxEntryBytes);
|
||||
StringBuilder? buffer = null;
|
||||
var bufferLines = 0;
|
||||
long entryStart = input.StartOffset;
|
||||
var skipping = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = reader.ReadLineAsync(System.Threading.CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (line == null) yield break;
|
||||
if (buffer == null) entryStart = reader.LastLineStart;
|
||||
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
buffer = null; bufferLines = 0; skipping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipping)
|
||||
{
|
||||
if (line.StartsWith("<![LOG[", StringComparison.Ordinal)) skipping = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains("]LOG]!>")) skipping = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer != null)
|
||||
{
|
||||
buffer.Append('\n').Append(line);
|
||||
bufferLines++;
|
||||
if (line.Contains("]LOG]!>"))
|
||||
{
|
||||
line = buffer.ToString();
|
||||
buffer = null; bufferLines = 0;
|
||||
}
|
||||
else if (bufferLines >= MaxEntryLines || buffer.Length >= maxEntryBytes)
|
||||
{
|
||||
buffer = null; bufferLines = 0; skipping = true;
|
||||
continue;
|
||||
}
|
||||
else continue;
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[", StringComparison.Ordinal) && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
buffer = new StringBuilder(line);
|
||||
bufferLines = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CmTraceLogParser.TryParseLine(line, out var entry) || entry == null) continue;
|
||||
yield return new ScannedEntry
|
||||
{
|
||||
Offset = entryStart,
|
||||
HasTimestamp = entry.HasTimestamp && !entry.BiasMinutes.HasValue,
|
||||
LocalTimestamp = entry.LocalTimestamp,
|
||||
Message = entry.Message ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-1
@@ -263,6 +263,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry entry;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entryStart;
|
||||
if (CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
messageToMatch = entry.Message;
|
||||
@@ -312,6 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, Path.GetFileName(filePath), calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
@@ -489,7 +491,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"imeshutdown", "espphasedetected", "setcurrentapp",
|
||||
"updatestateinstalled", "updatestatedownloading", "updatestateinstalling",
|
||||
"updatestateskipped", "updatestateerror", "updatestatepostponed",
|
||||
"captureexitcode", "capturehresult", "captureappversion",
|
||||
"captureexitcode", "capturehresult", "captureexitcodeclass", "captureappversion",
|
||||
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
@@ -509,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastMatchedSourceOffsetMinutes = null;
|
||||
LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.None;
|
||||
LastMatchedMeasuredWriterOffsetMinutes = null;
|
||||
LastMatchedEraAnchorKind = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -518,6 +521,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastMatchedSourceLocalTimestamp = entry.HasTimestamp ? entry.LocalTimestamp : (DateTime?)null;
|
||||
LastMatchedSourceOffsetMinutes = offsetMinutes;
|
||||
LastMatchedSourceOffsetOrigin = origin;
|
||||
LastMatchedEraAnchorKind = origin == CmTraceOffsetOrigin.EraAnchored ? _lastEraAnchorKind : string.Empty;
|
||||
|
||||
// Observational: what the calibrator measured for this file, distinct from what
|
||||
// was applied above.
|
||||
@@ -555,7 +559,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Placed after the staleness gate so a success replayed from a previous
|
||||
// enrollment's log cannot clear a genuine current outage.
|
||||
if (!isStaleReplayLine && string.Equals(pattern.PatternId, "IME-TOKEN-SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ClearPendingTokenFailure();
|
||||
// Raw user-token observation for the Entra user-affinity detector (2026-09-04).
|
||||
// Deliberately NOT routed through espPhaseDetected: that path deduplicates once
|
||||
// AccountSetup is set, so a FullESP session would never report the token that
|
||||
// followed the real-user desktop.
|
||||
try { OnUserTokenAcquired?.Invoke(LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger?.Warning($"OnUserTokenAcquired handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -617,6 +629,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
case "imetokenfailure":
|
||||
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
|
||||
// Raw per-line observation (staleness already enforced above via
|
||||
// AppMutatingActions) — the affinity detector counts failures after
|
||||
// the real-user desktop; the grace-window Warning stays separate.
|
||||
try { OnTokenFailureLine?.Invoke(match.Groups["errorCode"]?.Value, LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger?.Warning($"OnTokenFailureLine handler threw: {ex.Message}"); }
|
||||
break;
|
||||
|
||||
case "imeimpersonation":
|
||||
@@ -698,6 +715,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal);
|
||||
break;
|
||||
|
||||
case "captureexitcodeclass":
|
||||
// Return-code class of the exit code captured just before (IME logs the
|
||||
// number, then "lpExitCode is defined as <class>"). Same current-app
|
||||
// addressing as captureExitCode; last value wins across a Retry loop.
|
||||
var exitCodeClassVal = match.Groups["exitCodeClass"]?.Value;
|
||||
if (!string.IsNullOrEmpty(exitCodeClassVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCodeClass(exitCodeClassVal);
|
||||
break;
|
||||
|
||||
case "captureappversion":
|
||||
var appVersionVal = match.Groups["appVersion"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal))
|
||||
@@ -941,6 +967,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
// ERA ANCHOR (2026-09-04, session a7140f98): a backlog line whose writer era was
|
||||
// anchored by the bootstrap execution record resolves through that era's offset.
|
||||
// Whole era or nothing — the anchor never crosses an "EMS Agent Started" boundary,
|
||||
// and in-process sibling files (AppWorkload, AppActionProcessor) join by local
|
||||
// time only while the service eras' local ranges are unambiguous. See
|
||||
// ImeLogEraPreScan for why this is the one anchor a backlog line can trust.
|
||||
_lastEraAnchorKind = string.Empty;
|
||||
if (_currentSourceFileName != null && _currentEntryOffset >= 0)
|
||||
{
|
||||
TimeSpan eraOffset;
|
||||
string anchorKind;
|
||||
var resolved = EraTable.TryResolveByOffset(_currentSourceFileName, _currentEntryOffset, out eraOffset, out anchorKind)
|
||||
|| (ImeLogEraPreScan.IsServiceHostedFile(_currentSourceFileName)
|
||||
&& EraTable.TryResolveByLocalTime(entry.LocalTimestamp, out eraOffset, out anchorKind));
|
||||
if (resolved)
|
||||
{
|
||||
origin = CmTraceOffsetOrigin.EraAnchored;
|
||||
offsetMinutes = (int)eraOffset.TotalMinutes;
|
||||
_lastEraAnchorKind = anchorKind;
|
||||
return DateTime.SpecifyKind(entry.LocalTimestamp - eraOffset, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for everything not provably fresh: assume the reader's zone, UNIFORMLY.
|
||||
// Wrong in absolute terms whenever the writer held a different belief, but
|
||||
// self-consistent — both ends of a duration are wrong by the same amount, so derived
|
||||
|
||||
+92
-1
@@ -223,7 +223,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
private const int MaxScriptOutputLength = 2048;
|
||||
// Script stdout kept per event. Measured over 1,486 script outputs in the fleet
|
||||
// (2026-08-25..09-08): median 39, p90 644, max 1,501 chars -- the maximum being our own
|
||||
// one-stage bootstrap, and nothing ever hit the previous 2,048 cap. The two-stage
|
||||
// bootstrap then added a ~460-char loader preamble to that maximum and started
|
||||
// truncating the one output an operator actually reads. 8,192 leaves room for the
|
||||
// realistic worst case (download retries plus relax logging, ~2,700) and stays 4x below
|
||||
// the 32K-char Table Storage property limit that bounds the event downstream. Growth
|
||||
// driver is the number of log lines the bootstrap chain writes, so re-measure before
|
||||
// adding a chatty step rather than raising this again.
|
||||
private const int MaxScriptOutputLength = 8192;
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
// Size cap for ONE entry — a single physical line (enforced by BoundedLineReader before
|
||||
// the line is materialized) and an assembled multiline entry alike. Any process able to
|
||||
@@ -400,6 +409,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>How that offset was arrived at — declared, measured, or neither.</summary>
|
||||
public CmTraceOffsetOrigin LastMatchedSourceOffsetOrigin { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// For <see cref="CmTraceOffsetOrigin.EraAnchored"/>: which anchor established the era's
|
||||
/// offset ("bootstrap-policy-result", "bootstrap-stdout", with "/service-era-transfer"
|
||||
/// when the line came from an in-process sibling file). Empty otherwise.
|
||||
/// </summary>
|
||||
public string LastMatchedEraAnchorKind { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source of the install marker's UTC instant for the backlog era pre-scan
|
||||
/// (<see cref="ImeLogEraPreScan"/>). Defaults to the registry marker; tests inject.
|
||||
/// </summary>
|
||||
internal Func<DateTime?> DeployedUtcProvider { get; set; } = Security.DeploymentMarker.TryReadDeployedUtc;
|
||||
|
||||
/// <summary>Era table built by <see cref="PreScanEras"/> at <see cref="Start"/>; empty until then.</summary>
|
||||
internal CmTraceEraTable EraTable { get; private set; } = new CmTraceEraTable();
|
||||
|
||||
// Byte offset of the entry currently being resolved in the read loop (-1 outside it) —
|
||||
// the key for the era lookup. Set next to _currentSourceFileName.
|
||||
private long _currentEntryOffset = -1;
|
||||
private string _lastEraAnchorKind = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The offset the calibrator MEASURED for the source file, which is not necessarily the
|
||||
/// one applied.
|
||||
@@ -425,6 +455,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
|
||||
// Fired once per distinct errorCode per tracker lifetime (see HandleImeTokenFailure).
|
||||
public Action<string, string> OnImeTokenFailure { get; set; }
|
||||
/// <summary>
|
||||
/// Fires on EVERY IME-TOKEN-SUCCESS line that passes the historic-replay gate, with the
|
||||
/// line's resolved UTC (null when the line carried no timestamp). Unlike
|
||||
/// <see cref="OnEspPhaseChanged"/> (deduplicated once AccountSetup is set) and unlike the
|
||||
/// grace-window model behind <see cref="OnImeTokenFailure"/>, this is the raw observation:
|
||||
/// "Successfully get the token" is IntuneTokenManager acquiring the signed-in user's token
|
||||
/// through WAM, i.e. the only SYSTEM-visible proof of Entra user affinity (2026-09-04).
|
||||
/// </summary>
|
||||
public Action<DateTime?> OnUserTokenAcquired { get; set; }
|
||||
/// <summary>
|
||||
/// Fires on EVERY IME-TOKEN-FAILURE line that passes the historic-replay gate:
|
||||
/// errorCode (may be empty) and the line's resolved UTC. Raw observation — the
|
||||
/// deduplicated Warning stays on <see cref="OnImeTokenFailure"/>.
|
||||
/// </summary>
|
||||
public Action<string, DateTime?> OnTokenFailureLine { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
@@ -839,6 +884,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the backlog era table from the files the first pass will read, each from the
|
||||
/// position the pass will start at. Never throws — a failure leaves the table empty and
|
||||
/// every backlog line on the reader-zone fallback, exactly as before.
|
||||
/// </summary>
|
||||
internal void PreScanEras()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_logFolder)) return;
|
||||
var inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
var files = new List<string>();
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
if (!info.Exists) continue;
|
||||
inputs.Add(new ImeLogEraPreScan.ScanInput(filePath, _positionTracker.GetSafePosition(filePath, info.Length)));
|
||||
}
|
||||
|
||||
DateTime? deployedUtc = null;
|
||||
try { deployedUtc = DeployedUtcProvider?.Invoke(); }
|
||||
catch (Exception ex) { _logger.Debug($"ImeLogTracker: Deployed marker read failed: {ex.Message}"); }
|
||||
|
||||
EraTable = ImeLogEraPreScan.Build(inputs, deployedUtc, MaxEntryBytes, _logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: era pre-scan failed — backlog stays on the reader-zone fallback: {ex.Message}");
|
||||
EraTable = new CmTraceEraTable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background polling task
|
||||
/// </summary>
|
||||
@@ -851,6 +934,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// exactly where we left off — no re-parsing, no device-phase bleeding.
|
||||
LoadState();
|
||||
|
||||
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
|
||||
// before the poll loop so every replayed line already finds its era; read-only on
|
||||
// the position tracker. Replay tooling anchors nothing — its logs did not come from
|
||||
// this device's bootstrap.
|
||||
if (!SimulationMode) PreScanEras();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
@@ -1161,6 +1250,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ErrorCode = p.ErrorCode,
|
||||
ExitCode = p.ExitCode,
|
||||
HResultFromWin32 = p.HResultFromWin32,
|
||||
ExitCodeClass = p.ExitCodeClass,
|
||||
DoFileSize = p.DoFileSize,
|
||||
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
|
||||
DoBytesFromPeers = p.DoBytesFromPeers,
|
||||
@@ -1190,6 +1280,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
|
||||
errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
|
||||
exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
|
||||
exitCodeClass: p.ExitCodeClass,
|
||||
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
|
||||
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
|
||||
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
|
||||
|
||||
+3
@@ -190,6 +190,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Installer result codes
|
||||
public string ExitCode { get; set; }
|
||||
public string HResultFromWin32 { get; set; }
|
||||
// Return-code class of the last exit code (null on state files written before the field
|
||||
// existed) - must survive the reboot a SoftReboot/HardReboot app asks for.
|
||||
public string ExitCodeClass { get; set; }
|
||||
|
||||
// Delivery Optimization telemetry
|
||||
public long DoFileSize { get; set; }
|
||||
|
||||
+11
@@ -67,6 +67,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public event EventHandler<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
|
||||
public event EventHandler<AadUserJoinedEventArgs> AadUserJoined;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> while a provisioning placeholder (foouser@/autopilot@) is the JoinInfo
|
||||
/// user and no real user has replaced it. On a Hybrid join this stays true through the
|
||||
/// whole user sign-in — the AD sign-in never touches JoinInfo — so consumers report it
|
||||
/// as a fact, never as sign-in evidence (2026-09-04).
|
||||
/// </summary>
|
||||
internal bool PlaceholderObservedWithoutRealUser
|
||||
{
|
||||
get { lock (_stateLock) return _placeholderFired && !_realUserFired; }
|
||||
}
|
||||
|
||||
public AadJoinWatcher(AgentLogger logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
+2
-2
@@ -234,7 +234,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_excluded_user",
|
||||
SharedEventTypes.DesktopExcludedUser,
|
||||
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
|
||||
}
|
||||
@@ -250,7 +250,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_real_user_detected",
|
||||
SharedEventTypes.DesktopRealUserDetected,
|
||||
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
|
||||
}
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-shot detector for Entra user affinity on Hybrid Azure AD Join devices
|
||||
/// (2026-09-04, session a7140f98). After the real user signs in, IME acquires that user's
|
||||
/// token through WAM and logs "Successfully get the token" (IntuneTokenManager,
|
||||
/// <c>IME-TOKEN-SUCCESS</c>). That line is the only SYSTEM-visible proof that the user
|
||||
/// session carries an Entra identity (PRT); a session whose user never gets one shows only
|
||||
/// "Failed to get AAD token" lines after the desktop.
|
||||
/// <para>
|
||||
/// Contract: <see cref="NotifyRealUserDesktop"/> arms the timer when the device is a Hybrid
|
||||
/// join (probe injected by the host). The first token success stamped after the desktop
|
||||
/// cancels the timer and emits <c>ime_user_token_acquired</c>; the timer emits
|
||||
/// <c>entra_user_affinity_pending</c> with the failure codes observed since the desktop.
|
||||
/// A token that arrives after the timer fired is still posted once — it tells the
|
||||
/// timeline (and the backend rule) that affinity came late rather than never.
|
||||
/// Both emissions happen at most once per agent process — a reboot starts a new process,
|
||||
/// whose repeat is exactly the confidence signal the backend rule counts. Token lines older
|
||||
/// than the desktop (minus a small tolerance for the 30 s desktop poll) belong to the
|
||||
/// device phase and are ignored: they appear in every enrollment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not a decision signal. Never influences completion. The JoinInfo placeholder is reported
|
||||
/// as a fact in the payload because it is NOT a login signal on a Hybrid join — the AD
|
||||
/// sign-in never replaces it; only the later Entra device registration does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EntraUserAffinityDetector : IDisposable
|
||||
{
|
||||
internal const int DefaultDelayMinutes = 10;
|
||||
internal const string SourceLabel = "EntraUserAffinityDetector";
|
||||
/// <summary>Token lines this much older than the desktop observation still count as post-desktop.</summary>
|
||||
internal static readonly TimeSpan DesktopPollTolerance = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<bool> _isHybridJoinProbe;
|
||||
private readonly Func<bool>? _placeholderActiveProbe;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<string> _failureCodesSinceDesktop = new List<string>();
|
||||
|
||||
private Timer? _timer;
|
||||
private DateTime? _desktopObservedUtc;
|
||||
private bool _armed;
|
||||
private bool _fired;
|
||||
private bool _tokenAcquiredPosted;
|
||||
private int _failureCountSinceDesktop;
|
||||
private int _disposed;
|
||||
|
||||
internal EntraUserAffinityDetector(
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
Func<bool> isHybridJoinProbe,
|
||||
Func<bool>? placeholderActiveProbe = null,
|
||||
TimeSpan? delay = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_isHybridJoinProbe = isHybridJoinProbe ?? throw new ArgumentNullException(nameof(isHybridJoinProbe));
|
||||
_placeholderActiveProbe = placeholderActiveProbe;
|
||||
_delay = delay ?? TimeSpan.FromMinutes(DefaultDelayMinutes);
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real (non-placeholder,
|
||||
/// non-system) user. Arms the timer on Hybrid devices; idempotent; no-op after a
|
||||
/// token success or after the timer fired.
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
bool hybrid;
|
||||
try { hybrid = _isHybridJoinProbe(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EntraUserAffinityDetector: hybrid probe threw — not arming: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || _armed || _fired || _tokenAcquiredPosted) return;
|
||||
if (!hybrid)
|
||||
{
|
||||
_logger.Debug("EntraUserAffinityDetector: real-user desktop on a non-Hybrid device — not armed");
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopObservedUtc = _utcNow();
|
||||
_armed = true;
|
||||
_logger.Info(
|
||||
$"EntraUserAffinityDetector: armed at real-user desktop — will emit {SharedConstants.EventTypes.EntraUserAffinityPending} in {_delay.TotalMinutes:F0} min unless IME acquires a user token");
|
||||
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>IME-TOKEN-SUCCESS line observed; <paramref name="lineUtc"/> is the line's resolved time.</summary>
|
||||
public void NotifyUserTokenAcquired(DateTime? lineUtc)
|
||||
{
|
||||
DateTime desktop;
|
||||
double minutesAfterDesktop;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _tokenAcquiredPosted) return;
|
||||
desktop = _desktopObservedUtc!.Value;
|
||||
if (!IsAfterDesktop(lineUtc, desktop)) return;
|
||||
|
||||
_tokenAcquiredPosted = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
minutesAfterDesktop = ((lineUtc ?? _utcNow()) - desktop).TotalMinutes;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["minutesAfterDesktop"] = Math.Max(0, minutesAfterDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailuresBeforeSuccess"] = _failureCountSinceDesktop.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["isHybridJoin"] = "true",
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.ImeUserTokenAcquired,
|
||||
source: SourceLabel,
|
||||
message: "Entra user token acquired by IME after the real-user desktop — user affinity established",
|
||||
severity: EventSeverity.Info,
|
||||
immediateUpload: false,
|
||||
data: data,
|
||||
occurredAtUtc: lineUtc);
|
||||
|
||||
_logger.Info($"EntraUserAffinityDetector: user token observed {minutesAfterDesktop:F1} min after desktop — timer cancelled");
|
||||
}
|
||||
|
||||
/// <summary>IME-TOKEN-FAILURE line observed; counted only when stamped after the desktop.</summary>
|
||||
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
|
||||
if (!IsAfterDesktop(lineUtc, _desktopObservedUtc!.Value)) return;
|
||||
|
||||
_failureCountSinceDesktop++;
|
||||
var code = string.IsNullOrWhiteSpace(errorCode) ? "unknown" : errorCode!.Trim();
|
||||
if (!_failureCodesSinceDesktop.Contains(code, StringComparer.OrdinalIgnoreCase))
|
||||
_failureCodesSinceDesktop.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test seams — same lock and state guards as the production paths.
|
||||
internal void TriggerFromTest() => OnTimer(null);
|
||||
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
|
||||
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
|
||||
internal bool TokenAcquiredPostedForTest { get { lock (_lock) { return _tokenAcquiredPosted; } } }
|
||||
|
||||
private static bool IsAfterDesktop(DateTime? lineUtc, DateTime desktopUtc)
|
||||
{
|
||||
// A line without a timestamp cannot be placed — treat it as current (the tracker
|
||||
// only hands us lines it just read), which is after the desktop by construction.
|
||||
if (!lineUtc.HasValue) return true;
|
||||
return lineUtc.Value >= desktopUtc - DesktopPollTolerance;
|
||||
}
|
||||
|
||||
private void OnTimer(object? state)
|
||||
{
|
||||
string codes;
|
||||
int count;
|
||||
double minutesSinceDesktop;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
|
||||
_fired = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
codes = string.Join(",", _failureCodesSinceDesktop);
|
||||
count = _failureCountSinceDesktop;
|
||||
minutesSinceDesktop = (_utcNow() - _desktopObservedUtc!.Value).TotalMinutes;
|
||||
}
|
||||
|
||||
var placeholderActive = "unknown";
|
||||
try
|
||||
{
|
||||
if (_placeholderActiveProbe != null)
|
||||
placeholderActive = _placeholderActiveProbe() ? "true" : "false";
|
||||
}
|
||||
catch { /* observational */ }
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["reason"] = "timer_fired",
|
||||
["minutesSinceDesktop"] = Math.Max(0, minutesSinceDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailureCount"] = count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailureCodes"] = codes,
|
||||
["placeholderActive"] = placeholderActive,
|
||||
["isHybridJoin"] = "true",
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.EntraUserAffinityPending,
|
||||
source: SourceLabel,
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after the real-user desktop IME has not acquired an Entra user token — user affinity pending"
|
||||
+ (count > 0 ? $" ({count} token failure(s): {codes})" : string.Empty),
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data);
|
||||
|
||||
_logger.Warning($"EntraUserAffinityDetector: emitted {SharedConstants.EventTypes.EntraUserAffinityPending} (failures={count}, codes={codes})");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -36,6 +36,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal const int EventId_ProvisioningWillNotLaunch = 360;
|
||||
internal const int EventId_ProvisioningBlocked = 362;
|
||||
internal const int EventId_PinStatus = 376;
|
||||
// Automatic device registration failed at the join / authentication phase (Hybrid Join
|
||||
// affinity diagnosis, 2026-09-04). Not Hello events — they ride the same UDR watcher
|
||||
// because the channel is already armed; surfaced as device_registration_event.
|
||||
internal const int EventId_AutoRegistrationFailedJoin = 304;
|
||||
internal const int EventId_AutoRegistrationFailedAuth = 305;
|
||||
|
||||
internal const int EventId_HelloForBusiness_ProcessingStarted = 3024;
|
||||
internal const int EventId_HelloForBusiness_ProcessingStopped = 6045;
|
||||
@@ -47,6 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_AutoRegistrationFailedJoin,
|
||||
EventId_AutoRegistrationFailedAuth,
|
||||
EventId_NgcKeyRegistered,
|
||||
EventId_NgcKeyRegistrationFailed,
|
||||
EventId_ProvisioningWillLaunch,
|
||||
@@ -547,7 +554,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var query = new EventLogQuery(
|
||||
UdrEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
|
||||
"*[System[(EventID=300 or EventID=301 or EventID=304 or EventID=305 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
|
||||
|
||||
_udrWatcher = new EventLogWatcher(query);
|
||||
_udrWatcher.EventRecordWritten += OnUdrEventRecordWritten;
|
||||
@@ -654,6 +661,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
message = "Windows Hello PIN status update";
|
||||
break;
|
||||
|
||||
case EventId_AutoRegistrationFailedJoin: // 304
|
||||
case EventId_AutoRegistrationFailedAuth: // 305
|
||||
// OS-side record that the automatic device registration failed (join or
|
||||
// authentication phase). Companion evidence for entra_user_affinity_pending —
|
||||
// no Hello state involved, so no MarkHelloCompleted().
|
||||
eventType = Constants.EventTypes.DeviceRegistrationEvent;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"User Device Registration: automatic registration failed (event {eventId}, "
|
||||
+ (eventId == EventId_AutoRegistrationFailedJoin ? "join phase)" : "authentication phase)");
|
||||
_logger.Info($"User Device Registration failure event {eventId} observed");
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -691,6 +710,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// flip state multiple times — keep batched.
|
||||
ImmediateUpload = shouldTriggerHelloCompleted
|
||||
|| eventType == Constants.EventTypes.HelloProvisioningFailed
|
||||
|| eventType == Constants.EventTypes.DeviceRegistrationEvent
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
+52
-17
@@ -10,19 +10,27 @@ using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-shot detector for the Hybrid User-Driven completion-gap (2026-05-01 trigger:
|
||||
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After a Hybrid AAD-Join reboot the
|
||||
/// agent restarts and waits for the user to log in with their real AD/AAD account
|
||||
/// (which replaces the foouser/autopilot placeholder in JoinInfo). When that login is
|
||||
/// overdue the agent today goes silent until the backend 5-h watchdog fires; this
|
||||
/// detector emits a <c>hybrid_login_pending</c> warning event after a short single-shot
|
||||
/// timer so the operator sees an explicit "stuck waiting for AD login" signal in the
|
||||
/// timeline.
|
||||
/// Single-shot detector for the Hybrid User-Driven sign-in gap (2026-05-01 trigger:
|
||||
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After the Hybrid AAD-Join reboot the
|
||||
/// agent restarts and waits for the user to sign in with their AD account. When nobody
|
||||
/// signs in the agent goes silent until the backend watchdog fires; this detector emits a
|
||||
/// <c>hybrid_login_pending</c> warning after a short single-shot timer so the operator sees
|
||||
/// an explicit "still waiting for the sign-in" signal in the timeline.
|
||||
/// <para>
|
||||
/// Conditions checked at <see cref="Arm"/>: composition root must have already
|
||||
/// confirmed a) post-reboot, b) <c>isHybridJoin == true</c>. The detector itself only
|
||||
/// owns the timer + <see cref="AadJoinWatcher.AadUserJoined"/> cancel-path. No polling.
|
||||
/// No periodicity. Fires at most once per agent process.
|
||||
/// What it measures (rewritten 2026-09-04, session a7140f98): the ABSENCE of a real-user
|
||||
/// desktop. Two cancel paths: the DesktopArrivalDetector resolving explorer.exe under a
|
||||
/// real user (the actual sign-in evidence), and the AadJoinWatcher seeing a real user in
|
||||
/// JoinInfo (the Entra-join flavour, where the OOBE sign-in completes the join). On a
|
||||
/// Hybrid join the AD sign-in never replaces the JoinInfo placeholder — only the later
|
||||
/// Entra device registration does — so the placeholder state is reported as a fact in the
|
||||
/// payload, never as the reason for the warning.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Conditions checked at <see cref="Arm"/> by the composition root: post-reboot,
|
||||
/// <c>isHybridJoin == true</c>, and not a WhiteGlove device (a sealed device legitimately
|
||||
/// waits days for its user; the session status AwaitingUser already describes that).
|
||||
/// The detector owns only the timer and the cancel paths. No polling. Fires at most once
|
||||
/// per agent process.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class HybridLoginPendingDetector : IDisposable
|
||||
@@ -40,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _armed;
|
||||
private bool _fired;
|
||||
private bool _cancelledByRealUser;
|
||||
private bool _cancelledByDesktop;
|
||||
private int _disposed;
|
||||
|
||||
internal HybridLoginPendingDetector(
|
||||
@@ -60,8 +69,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// Starts the single-shot timer. Idempotent — repeated calls after the first arm
|
||||
/// are no-ops. Has no effect if the watcher already saw a real AAD user (the
|
||||
/// arming was racy and the cancel won), or if the detector already fired.
|
||||
/// are no-ops. Has no effect if a real-user desktop or a real AAD user was already
|
||||
/// observed (the arming was racy and the cancel won), or if the detector already fired.
|
||||
/// </summary>
|
||||
public void Arm()
|
||||
{
|
||||
@@ -69,6 +78,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_disposed != 0) return;
|
||||
if (_armed || _fired) return;
|
||||
if (_cancelledByDesktop)
|
||||
{
|
||||
_logger.Info("HybridLoginPendingDetector: arm requested but a real-user desktop was already observed — skipped");
|
||||
return;
|
||||
}
|
||||
if (_cancelledByRealUser)
|
||||
{
|
||||
_logger.Info("HybridLoginPendingDetector: arm requested but real AAD user already joined — skipped");
|
||||
@@ -77,12 +91,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
_armed = true;
|
||||
_logger.Info(
|
||||
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if real AAD user has not joined by then");
|
||||
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if no real-user desktop appears by then");
|
||||
|
||||
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real user — the sign-in
|
||||
/// happened. Cancels a running timer; remembered so a later <see cref="Arm"/> is a no-op.
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_cancelledByDesktop = true;
|
||||
if (!_armed || _fired) return;
|
||||
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_logger.Info("HybridLoginPendingDetector: cancelled — real-user desktop observed before timeout");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
@@ -106,6 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
|
||||
internal bool IsCancelledByRealUserForTest { get { lock (_lock) { return _cancelledByRealUser; } } }
|
||||
internal bool IsCancelledByDesktopForTest { get { lock (_lock) { return _cancelledByDesktop; } } }
|
||||
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
|
||||
|
||||
private void OnTimer(object? state) => EmitInternal(reason: "timer_fired");
|
||||
@@ -130,23 +162,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// _armed guard (Codex review 2026-05-01): production is safe because the
|
||||
// timer is only created in Arm(), but explicit guard makes the contract
|
||||
// unambiguous — emission requires a deliberate Arm(), full stop.
|
||||
if (!_armed || _fired || _cancelledByRealUser || _disposed != 0) return;
|
||||
if (!_armed || _fired || _cancelledByRealUser || _cancelledByDesktop || _disposed != 0) return;
|
||||
_fired = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
var placeholderActive = _watcher.PlaceholderObservedWithoutRealUser ? "true" : "false";
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(),
|
||||
["reason"] = reason,
|
||||
["isHybridJoin"] = "true", // armed only when composition root verified this
|
||||
["realUserDesktopSeen"] = "false",
|
||||
["placeholderActive"] = placeholderActive,
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.HybridLoginPending,
|
||||
source: SourceLabel,
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real AD user — login overdue (placeholder still active in JoinInfo)",
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real-user desktop — sign-in overdue",
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data);
|
||||
|
||||
+18
-28
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
@@ -303,7 +304,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (TryNormalizeHResult(errorCode, out var hresultValue, out hresultHex))
|
||||
{
|
||||
data["hresult"] = hresultHex;
|
||||
data["hresultSymbol"] = DecodeHResult(hresultValue);
|
||||
data["hresultSymbol"] = ResolveHResultSymbol(hresultValue);
|
||||
}
|
||||
|
||||
var title = string.IsNullOrEmpty(updateTitle) ? "(unknown update)" : updateTitle;
|
||||
@@ -462,36 +463,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps common Windows Update HRESULTs to their symbolic name. Not exhaustive — the full
|
||||
/// catalog lives at
|
||||
/// https://learn.microsoft.com/windows/deployment/update/windows-update-error-reference.
|
||||
/// Unknown codes return "WU_E_UNKNOWN" so the raw <c>hresult</c> hex still carries the value.
|
||||
/// Symbolic name of a WU HRESULT from the shared error-code catalog (WU, CBS, Win32 and
|
||||
/// COM families). <c>S_OK</c> for zero, <c>WU_E_UNKNOWN</c> when the catalog has no symbol,
|
||||
/// so the raw <c>hresult</c> hex still carries the value. A catalog load failure is
|
||||
/// swallowed the same way: decoding is decoration and must never cost the event.
|
||||
/// </summary>
|
||||
internal static string DecodeHResult(uint hresult)
|
||||
internal static string ResolveHResultSymbol(uint hresult)
|
||||
=> ResolveHResultSymbol(hresult, code => ErrorCodeCatalog.TryLookup(code));
|
||||
|
||||
internal static string ResolveHResultSymbol(uint hresult, Func<string, ErrorCodeEntry> lookup)
|
||||
{
|
||||
switch (hresult)
|
||||
if (hresult == 0) return "S_OK";
|
||||
try
|
||||
{
|
||||
case 0x00000000: return "S_OK";
|
||||
// WU agent codes (0x8024xxxx)
|
||||
case 0x80240022: return "WU_E_ALL_UPDATES_FAILED";
|
||||
case 0x8024200B: return "WU_E_UH_INSTALLERFAILURE";
|
||||
case 0x80240020: return "WU_E_NO_INTERACTIVE_USER";
|
||||
case 0x80240016: return "WU_E_INSTALL_NOT_ALLOWED";
|
||||
case 0x8024000B: return "WU_E_CALL_CANCELLED";
|
||||
case 0x8024000C: return "WU_E_NOOP";
|
||||
case 0x80240FFF: return "WU_E_UNEXPECTED";
|
||||
case 0x80248007: return "WU_E_DS_NODATA";
|
||||
case 0x8024402C: return "WU_E_PT_WINHTTP_NAME_NOT_RESOLVED";
|
||||
// Generic Win32 / HRESULT
|
||||
case 0x80070005: return "E_ACCESSDENIED";
|
||||
case 0x80070002: return "ERROR_FILE_NOT_FOUND";
|
||||
case 0x80070003: return "ERROR_PATH_NOT_FOUND";
|
||||
case 0x800705B4: return "ERROR_TIMEOUT";
|
||||
// CBS / servicing (LCU install failures)
|
||||
case 0x800F0831: return "CBS_E_STORE_CORRUPTION";
|
||||
case 0x800F0922: return "CBS_E_INSTALLERS_FAILED";
|
||||
case 0x800F0991: return "PSFX_E_MISSING_PAYLOAD_FILE";
|
||||
default: return "WU_E_UNKNOWN";
|
||||
var symbol = lookup("0x" + hresult.ToString("x8", CultureInfo.InvariantCulture))?.Symbol;
|
||||
return string.IsNullOrEmpty(symbol) ? "WU_E_UNKNOWN" : symbol;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "WU_E_UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-1
@@ -65,6 +65,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
public static DiagnosticsUploadResult SkippedBy(string gate) =>
|
||||
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
|
||||
|
||||
/// <summary>
|
||||
/// Counters of the archive build that produced (or preceded) this result; null when no
|
||||
/// package was built (configuration gate, build exception). Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> / <c>diagnostics_upload_failed</c> events so a truncated or
|
||||
/// partially failed package is visible without opening the ZIP.
|
||||
/// </summary>
|
||||
public DiagnosticsPackagingStats Packaging { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the packager included, skipped and failed on — as bounded counters only. File paths
|
||||
/// stay inside the ZIP (<c>package-manifest.txt</c>, <c>_TRUNCATED.txt</c>): they can carry
|
||||
/// user names, and a skip list has no upper bound while this payload must stay small no
|
||||
/// matter how many files a cap rejected.
|
||||
/// </summary>
|
||||
public sealed class DiagnosticsPackagingStats
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>();
|
||||
|
||||
public int IncludedFiles { get; set; }
|
||||
public long IncludedBytes { get; set; }
|
||||
|
||||
/// <summary>Files rejected by a cap or a reparse-point check (the <c>_TRUNCATED.txt</c> list).</summary>
|
||||
public int SkippedFiles { get; set; }
|
||||
|
||||
/// <summary>True when at least one file was skipped — the same condition that writes <c>_TRUNCATED.txt</c>.</summary>
|
||||
public bool Truncated => SkippedFiles > 0;
|
||||
|
||||
/// <summary>Skip count per reason: <c>size</c>, <c>count</c>, <c>total</c>, <c>reparse</c>, <c>resolved-outside-folder</c>.</summary>
|
||||
public IReadOnlyDictionary<string, int> SkippedByReason { get; set; } = Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Packaging problems that are not cap skips, per kind: <c>path-guard</c> (configured path
|
||||
/// refused), <c>no-user-session</c> (user-profile token without a signed-in user),
|
||||
/// <c>folder-rejected</c>, <c>enumerate</c>, <c>open</c>, <c>copy</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> ProblemsByKind { get; set; } = Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,6 +172,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Skipped.Add(new SkipRecord(path, reason, size));
|
||||
|
||||
public bool HasSkips => Skipped.Count > 0;
|
||||
|
||||
// Non-cap problems (guard refusals, enumeration/open/copy failures) by kind — the
|
||||
// manifest names the files, the event carries only these counts.
|
||||
public Dictionary<string, int> Problems { get; } = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _problemKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Counts one problem of <paramref name="kind"/>. A section is walked once per search
|
||||
/// pattern, so a folder-level failure would otherwise be counted once per pattern —
|
||||
/// pass the folder as <paramref name="dedupeKey"/> to count it once.
|
||||
/// </summary>
|
||||
public void RecordProblem(string kind, string dedupeKey = null)
|
||||
{
|
||||
if (dedupeKey != null && !_problemKeys.Add(kind + "|" + dedupeKey)) return;
|
||||
Problems.TryGetValue(kind, out var n);
|
||||
Problems[kind] = n + 1;
|
||||
}
|
||||
|
||||
public DiagnosticsPackagingStats ToStats()
|
||||
{
|
||||
var byReason = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var s in Skipped)
|
||||
{
|
||||
byReason.TryGetValue(s.Reason, out var n);
|
||||
byReason[s.Reason] = n + 1;
|
||||
}
|
||||
return new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = FileCount,
|
||||
IncludedBytes = TotalBytes,
|
||||
SkippedFiles = Skipped.Count,
|
||||
SkippedByReason = byReason,
|
||||
ProblemsByKind = new Dictionary<string, int>(Problems, StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct SkipRecord
|
||||
@@ -262,6 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip";
|
||||
|
||||
var zipBytes = BuildArchiveBytes(enrollmentSucceeded);
|
||||
var packaging = LastPackaging;
|
||||
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
@@ -276,7 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend";
|
||||
_logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}");
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode };
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode, Packaging = packaging };
|
||||
}
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
@@ -305,6 +379,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +388,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
ErrorCode = uploadErrorCode,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -351,6 +427,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
/// <summary>Counters of the most recent <see cref="BuildArchiveBytes"/>; null before the first build.</summary>
|
||||
internal DiagnosticsPackagingStats LastPackaging { get; private set; }
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
@@ -391,6 +470,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
tracker.RecordProblem("path-guard");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
@@ -398,6 +478,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -427,6 +508,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
}
|
||||
|
||||
LastPackaging = tracker.ToStats();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -498,6 +580,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
|
||||
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -584,6 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics source folder rejected ({folderRejection}): {sourceFolder}");
|
||||
ManifestLine($"FOLDER REJECTED ({folderRejection}): {sourceFolder} (pattern '{searchPattern}')");
|
||||
tracker.RecordProblem("folder-rejected", sourceFolder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -597,6 +681,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
tracker.RecordProblem("enumerate", sourceFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -681,6 +766,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
tracker.RecordProblem("copy");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -720,6 +806,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
default:
|
||||
_logger.Warning($"Failed to open {file}: {rejection}");
|
||||
ManifestLine($"FAILED (open): {file}: {rejection}");
|
||||
tracker.RecordProblem("open");
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
|
||||
+2
@@ -278,6 +278,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
@@ -347,6 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
GatherRuleExecutor.StampRuleMarkers(rule, data);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
|
||||
+7
-40
@@ -70,12 +70,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@"C:\Windows\System32\config", // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
|
||||
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
|
||||
private static readonly string[] AllowedUserProfileSubdirs = new[]
|
||||
{
|
||||
@"AppData\Local",
|
||||
@"AppData\Roaming",
|
||||
};
|
||||
// The allowed user-profile subdirectories and the vendor allowlist under them live in
|
||||
// GatherRuleGuards, which owns the embedded guardrails.json — one copy for both guards.
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given path is allowed for diagnostics collection.
|
||||
@@ -135,7 +131,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
(normalizedDir.Length == BlockedUsersPrefix.Length ||
|
||||
normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
if (!IsUserProfileSubpathAllowed(normalizedDir, userProfilePath))
|
||||
if (!GatherRuleGuards.IsUserProfileSubpathAllowed(normalizedDir, userProfilePath))
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -169,7 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
// Vendor folders under the signed-in user's profile — same allowlist the file
|
||||
// collectors use, since it answers the same question: which folders below a
|
||||
// profile carry enrollment-relevant logs rather than the user's own data.
|
||||
return GatherRuleGuards.IsUserProfilePathOnAllowlist(normalizedDir, userProfilePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -178,38 +177,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the normalized path falls under one of the allowed subdirectories
|
||||
/// of the detected user profile (AppData\Local, AppData\Roaming).
|
||||
/// Only applies when %LOGGED_ON_USER_PROFILE% was used to resolve the path.
|
||||
/// </summary>
|
||||
internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userProfilePath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var normalizedProfile = Path.GetFullPath(userProfilePath);
|
||||
foreach (var subdir in AllowedUserProfileSubdirs)
|
||||
{
|
||||
var allowedPrefix = Path.GetFullPath(Path.Combine(normalizedProfile, subdir));
|
||||
if (normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == allowedPrefix.Length ||
|
||||
normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Path normalization failure — deny
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any component of the path (directories or file) is a symlink,
|
||||
/// junction, or other reparse point. Returns true if a reparse point is detected.
|
||||
|
||||
+13
-1
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
/// emits (executor, context guard events, collectors). The timeline feed uses it to keep
|
||||
/// gather output from re-triggering on_event rules.
|
||||
/// </summary>
|
||||
public const string SourceName = "GatherRuleExecutor";
|
||||
public const string SourceName = Constants.EventSources.GatherRuleExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on on_event executions per rule and session. on_event has no natural
|
||||
@@ -496,6 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
StampRuleMarkers(rule, result);
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : Constants.EventTypes.GatherResult;
|
||||
|
||||
@@ -537,6 +538,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// ===== Phase scope (ActivePhases / ActiveFromPhase) =====
|
||||
|
||||
/// <summary>
|
||||
/// Injects the per-rule opt-in markers the backend reads at response time. Called after
|
||||
/// the on_change hash is taken (the markers are rule config, not collected data) by the
|
||||
/// executor and by collectors that emit directly (logparser).
|
||||
/// </summary>
|
||||
internal static void StampRuleMarkers(GatherRule rule, Dictionary<string, object> data)
|
||||
{
|
||||
if (rule.EnrichErrorCodes)
|
||||
data[Constants.GatherRuleDataKeys.EnrichErrorCodes] = true;
|
||||
}
|
||||
|
||||
private static bool HasPhaseScope(GatherRule rule)
|
||||
{
|
||||
return (rule.ActivePhases != null && rule.ActivePhases.Count > 0)
|
||||
|
||||
+110
-6
@@ -32,6 +32,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly IReadOnlyList<string> AllowedRegistryPrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedFilePrefixes;
|
||||
|
||||
/// <summary>
|
||||
/// Folders under the signed-in user's profile that file collectors and
|
||||
/// admin-configured diagnostics paths may read, RELATIVE to the profile root
|
||||
/// (e.g. <c>AppData\Local\Vendor</c>). Resolved against the path
|
||||
/// %LOGGED_ON_USER_PROFILE% produced, at match time — the profile name is not
|
||||
/// known when the list is authored, which is why these entries cannot live in
|
||||
/// <see cref="AllowedFilePrefixes"/> (matched literally against the expanded path).
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> AllowedUserProfileFilePrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedWmiQueryPrefixes;
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
||||
@@ -63,8 +73,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
|
||||
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
|
||||
private static readonly string[] AllowedUserProfileSubdirs = new[]
|
||||
// The only subdirectories of a user profile the C:\Users hard block will ever
|
||||
// release (used with %LOGGED_ON_USER_PROFILE%). Single copy for both guards —
|
||||
// DiagnosticsPathGuards delegates here.
|
||||
internal static readonly string[] AllowedUserProfileSubdirs = new[]
|
||||
{
|
||||
@"AppData\Local",
|
||||
@"AppData\Roaming",
|
||||
@@ -118,6 +130,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
AllowedRegistryPrefixes = FlattenCategorized(obj, "registryPrefixes", "prefixes");
|
||||
AllowedFilePrefixes = obj["filePrefixes"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
AllowedUserProfileFilePrefixes = LoadUserProfilePrefixes(obj);
|
||||
AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
|
||||
var commands = FlattenCategorized(obj, "allowedCommands", "commands");
|
||||
@@ -136,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
AllowedRegistryPrefixes = new List<string>();
|
||||
AllowedFilePrefixes = new List<string>();
|
||||
AllowedUserProfileFilePrefixes = new List<string>();
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AllowedEventLogChannels = new List<string>();
|
||||
@@ -143,6 +157,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
ResidualWmiPrefixes = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>userProfileFilePrefixes</c> and drops every entry that is rooted, carries a
|
||||
/// traversal, or does not sit under one of <see cref="AllowedUserProfileSubdirs"/>.
|
||||
/// The C:\Users hard block releases nothing outside AppData, so such an entry could only
|
||||
/// ever be dead text — dropping it at load keeps the list honest instead of silently
|
||||
/// ineffective, and no configuration can widen the hard block's exception.
|
||||
/// </summary>
|
||||
private static List<string> LoadUserProfilePrefixes(JObject obj)
|
||||
{
|
||||
var raw = obj["userProfileFilePrefixes"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
var result = new List<string>();
|
||||
|
||||
foreach (var entry in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
continue;
|
||||
|
||||
var trimmed = entry.Trim().TrimEnd('\\');
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
continue;
|
||||
if (trimmed.IndexOf("..", StringComparison.Ordinal) >= 0)
|
||||
continue;
|
||||
|
||||
foreach (var subdir in AllowedUserProfileSubdirs)
|
||||
{
|
||||
if (trimmed.StartsWith(subdir, StringComparison.OrdinalIgnoreCase) &&
|
||||
(trimmed.Length == subdir.Length || trimmed[subdir.Length] == '\\'))
|
||||
{
|
||||
result.Add(trimmed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void DeriveWmiClassAllowlist(
|
||||
IReadOnlyList<string> entries,
|
||||
out HashSet<string> classes,
|
||||
@@ -265,12 +316,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// C:\Users block always applies (even in unrestricted mode)
|
||||
// Exception: paths under <userProfilePath>\AppData\Local or AppData\Roaming
|
||||
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used.
|
||||
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used. The exception
|
||||
// only LIFTS the block — in restricted mode the allowlist below still decides.
|
||||
if (normalizedPath.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == BlockedUsersPrefix.Length ||
|
||||
normalizedPath[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
if (!DiagnosticsPathGuards.IsUserProfileSubpathAllowed(normalizedPath, userProfilePath))
|
||||
if (!IsUserProfileSubpathAllowed(normalizedPath, userProfilePath))
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -288,9 +340,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedFilePrefixes.Any(prefix =>
|
||||
if (AllowedFilePrefixes.Any(prefix =>
|
||||
normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\'));
|
||||
(normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vendor folders under the signed-in user's profile. Only reachable when the rule
|
||||
// spelled %LOGGED_ON_USER_PROFILE% and the resolver found an interactive user —
|
||||
// a null profile denies, so a machine with nobody signed in cannot widen anything.
|
||||
return IsUserProfilePathOnAllowlist(normalizedPath, userProfilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the normalized path sits under one of <see cref="AllowedUserProfileSubdirs"/>
|
||||
/// of the detected user profile. This is the C:\Users hard block's only exception; it
|
||||
/// releases the block and nothing else. A null profile denies.
|
||||
/// </summary>
|
||||
internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath)
|
||||
=> AllowedUserProfileSubdirs.Any(subdir =>
|
||||
MatchesProfileRelativePrefix(normalizedPath, userProfilePath, subdir));
|
||||
|
||||
/// <summary>
|
||||
/// True when the normalized path is admitted by an entry of
|
||||
/// <see cref="AllowedUserProfileFilePrefixes"/> under the detected user profile.
|
||||
/// </summary>
|
||||
internal static bool IsUserProfilePathOnAllowlist(string normalizedPath, string userProfilePath)
|
||||
=> AllowedUserProfileFilePrefixes.Any(prefix =>
|
||||
MatchesProfileRelativePrefix(normalizedPath, userProfilePath, prefix));
|
||||
|
||||
/// <summary>
|
||||
/// Segment-bounded match of <paramref name="normalizedPath"/> against
|
||||
/// <profile>\<relativePrefix>. Both sides go through Path.GetFullPath, so a
|
||||
/// traversal spelled into either one cannot slip past the boundary check.
|
||||
/// </summary>
|
||||
private static bool MatchesProfileRelativePrefix(
|
||||
string normalizedPath, string userProfilePath, string relativePrefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(normalizedPath) || string.IsNullOrEmpty(userProfilePath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var allowedPrefix = Path.GetFullPath(
|
||||
Path.Combine(Path.GetFullPath(userProfilePath), relativePrefix));
|
||||
|
||||
return normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == allowedPrefix.Length ||
|
||||
normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Path normalization failure — deny.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
@@ -312,6 +313,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// later before the detector can fire). When the real-user desktop arrives, Shift+F10 is no
|
||||
// longer possible, so the console watcher is stopped to avoid post-enrollment false positives.
|
||||
ConsoleBypassHost? consoleBypassHost = null;
|
||||
// Captured likewise (assigned right after this host). The real-user desktop is the
|
||||
// sign-in evidence for the Hybrid detectors: it cancels hybrid_login_pending and arms
|
||||
// the Entra user-affinity timer (2026-09-04, session a7140f98).
|
||||
AadJoinHost? aadJoinHostRef = null;
|
||||
var desktopArrivalHost = new DesktopArrivalHost(
|
||||
logger,
|
||||
ingress,
|
||||
@@ -326,6 +331,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Shift+F10 works — stop the console watcher (no-op if not created / already stopped).
|
||||
consoleBypassHost?.StopForDesktopArrival();
|
||||
|
||||
aadJoinHostRef?.NotifyRealUserDesktop();
|
||||
|
||||
if (realmJoinHost == null) return;
|
||||
if (UserSidResolver.TryResolveSid(owner, out var sid) && !string.IsNullOrEmpty(sid))
|
||||
{
|
||||
@@ -340,8 +347,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
var aadJoinHost = new AadJoinHost(
|
||||
logger, ingress, clock,
|
||||
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch);
|
||||
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch,
|
||||
isHybridJoinProbe: EnrollmentRegistryDetector.DetectHybridJoin);
|
||||
hosts.Add(aadJoinHost);
|
||||
aadJoinHostRef = aadJoinHost;
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
@@ -439,6 +448,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
hosts.Add(imeLogHost);
|
||||
imeLogHostRef = imeLogHost;
|
||||
|
||||
// Entra user affinity (2026-09-04): the raw IME token lines feed the AadJoinHost's
|
||||
// affinity detector. New callbacks — nothing chained before us, so direct assignment.
|
||||
imeLogHost.Tracker.OnUserTokenAcquired = lineUtc => aadJoinHost.NotifyUserTokenAcquired(lineUtc);
|
||||
imeLogHost.Tracker.OnTokenFailureLine = (code, lineUtc) => aadJoinHost.NotifyTokenFailureLine(code, lineUtc);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
|
||||
// page exits while apps are still in flight, the single attempt misses, and nothing
|
||||
|
||||
@@ -258,6 +258,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public bool IsWhiteGlovePart2 => _isWhiteGlovePart2;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when this device ever resumed a WhiteGlove Part 2 in this session — the
|
||||
/// state folder holds a <c>.part1-<ts>/</c> archive. Unlike
|
||||
/// <see cref="IsWhiteGlovePart2"/> (this start only) it survives later reboots within
|
||||
/// Part 2; see <see cref="StateArchiver.HasPart1Archive"/>.
|
||||
/// </summary>
|
||||
public bool HasWhiteGlovePart1Archive => StateArchiver.HasPart1Archive(_stateDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> wenn der Start auf einen korrupten State-Segment traf und
|
||||
/// Snapshot + Log-Segmente nach <c>.quarantine/{ts}/</c> bewegt wurden.
|
||||
|
||||
@@ -8,6 +8,13 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Hybrid-identity host: the JoinInfo watcher plus the two single-shot detectors that
|
||||
/// describe the user side of a Hybrid Azure AD Join — sign-in overdue
|
||||
/// (<see cref="HybridLoginPendingDetector"/>) and Entra user affinity after the sign-in
|
||||
/// (<see cref="EntraUserAffinityDetector"/>). The composition root feeds it the
|
||||
/// DesktopArrivalDetector's real-user observation and the ImeLogTracker's raw token lines.
|
||||
/// </summary>
|
||||
internal sealed class AadJoinHost : ICollectorHost
|
||||
{
|
||||
public string Name => "AadJoinWatcher";
|
||||
@@ -15,41 +22,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly AadJoinWatcher _watcher;
|
||||
private readonly AadJoinWatcherAdapter _adapter;
|
||||
private readonly HybridLoginPendingDetector _hybridLoginPendingDetector;
|
||||
private readonly EntraUserAffinityDetector _userAffinityDetector;
|
||||
private int _disposed;
|
||||
|
||||
public AadJoinHost(
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Action? onRealUserJoined = null)
|
||||
Action? onRealUserJoined = null,
|
||||
Func<bool>? isHybridJoinProbe = null)
|
||||
{
|
||||
_watcher = new AadJoinWatcher(logger);
|
||||
_adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock, onRealUserJoined: onRealUserJoined);
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_hybridLoginPendingDetector = new HybridLoginPendingDetector(
|
||||
watcher: _watcher,
|
||||
post: new InformationalEventPost(ingress, clock),
|
||||
post: post,
|
||||
logger: logger);
|
||||
_userAffinityDetector = new EntraUserAffinityDetector(
|
||||
post: post,
|
||||
logger: logger,
|
||||
isHybridJoinProbe: isHybridJoinProbe ?? (() => false),
|
||||
placeholderActiveProbe: () => _watcher.PlaceholderObservedWithoutRealUser,
|
||||
utcNow: () => clock.UtcNow);
|
||||
}
|
||||
|
||||
public void Start() => _watcher.Start();
|
||||
public void Stop() => _watcher.Stop();
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for the Hybrid User-Driven completion-gap fix
|
||||
/// (2026-05-01). Only call when both prerequisites hold: a) the prior agent process
|
||||
/// was killed by an OS reboot (<c>previousExitType=reboot_kill</c>), b) the
|
||||
/// Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>). The detector
|
||||
/// itself enforces single-shot semantics + cancel-on-real-user — repeated calls or
|
||||
/// late calls after a real user joined are safe no-ops.
|
||||
/// Composition-root entry point for the Hybrid User-Driven sign-in-overdue warning
|
||||
/// (2026-05-01, semantics 2026-09-04). Only call when all prerequisites hold: a) the
|
||||
/// prior agent process was killed by an OS reboot (<c>previousExitType=reboot_kill</c>),
|
||||
/// b) the Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>), c) the device
|
||||
/// is not a WhiteGlove device (neither resuming Part 2 nor carrying a Part-1 archive).
|
||||
/// The detector itself enforces single-shot semantics and both cancel paths —
|
||||
/// repeated calls or late calls after a desktop / real user are safe no-ops.
|
||||
/// </summary>
|
||||
public void ArmHybridLoginPendingDetector() => _hybridLoginPendingDetector.Arm();
|
||||
|
||||
// Test seam — exposes the detector for unit tests in the V2.Core.Tests project.
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real user: cancels the
|
||||
/// sign-in-overdue timer and arms the user-affinity timer (Hybrid devices only).
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
try { _hybridLoginPendingDetector.NotifyRealUserDesktop(); }
|
||||
catch (Exception ex) { LogCallbackFailure("HybridLoginPendingDetector.NotifyRealUserDesktop", ex); }
|
||||
try { _userAffinityDetector.NotifyRealUserDesktop(); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyRealUserDesktop", ex); }
|
||||
}
|
||||
|
||||
/// <summary>ImeLogTracker observed an IME-TOKEN-SUCCESS line (raw, every line).</summary>
|
||||
public void NotifyUserTokenAcquired(DateTime? lineUtc)
|
||||
{
|
||||
try { _userAffinityDetector.NotifyUserTokenAcquired(lineUtc); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyUserTokenAcquired", ex); }
|
||||
}
|
||||
|
||||
/// <summary>ImeLogTracker observed an IME-TOKEN-FAILURE line (raw, every line).</summary>
|
||||
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
|
||||
{
|
||||
try { _userAffinityDetector.NotifyTokenFailureLine(errorCode, lineUtc); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyTokenFailureLine", ex); }
|
||||
}
|
||||
|
||||
// Test seams — expose the detectors for unit tests in the V2.Core.Tests project.
|
||||
internal HybridLoginPendingDetector HybridLoginPendingDetectorForTest => _hybridLoginPendingDetector;
|
||||
internal EntraUserAffinityDetector UserAffinityDetectorForTest => _userAffinityDetector;
|
||||
|
||||
private static void LogCallbackFailure(string what, Exception ex)
|
||||
{
|
||||
// Host callbacks are best-effort — a detector fault must never break the caller
|
||||
// (DesktopArrivalDetector poll / ImeLogTracker drain). Swallowed on purpose; the
|
||||
// detectors log their own state transitions.
|
||||
_ = what; _ = ex;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _userAffinityDetector.Dispose(); } catch { }
|
||||
try { _hybridLoginPendingDetector.Dispose(); } catch { }
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
|
||||
@@ -37,6 +37,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public static class StateArchiver
|
||||
{
|
||||
internal const string Part1BucketPrefix = ".part1-";
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="stateDirectory"/> holds at least one archived
|
||||
/// <c>.part1-<ts>/</c> bucket — i.e. this device already went through a WhiteGlove
|
||||
/// Part-1/Part-2 resume in this session. Durable across later reboots, unlike the
|
||||
/// <c>whiteglove.complete</c> marker (cleared on resume) and the orchestrator's
|
||||
/// in-memory Part-2 flag. Used to keep sign-in-overdue warnings off pre-provisioned
|
||||
/// devices that legitimately wait days for their user (2026-09-04).
|
||||
/// </summary>
|
||||
public static bool HasPart1Archive(string stateDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(stateDirectory) || !Directory.Exists(stateDirectory)) return false;
|
||||
return Directory.GetDirectories(stateDirectory, Part1BucketPrefix + "*").Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reducer-state segment files moved into the <c>.part1-<ts>/</c> bucket on
|
||||
/// archive. <c>event-sequence.json</c> is intentionally NOT in this list — see the
|
||||
@@ -86,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (!hasAny) return null;
|
||||
|
||||
var stamp = utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'");
|
||||
var bucket = Path.Combine(stateDirectory, ".part1-" + stamp);
|
||||
var bucket = Path.Combine(stateDirectory, Part1BucketPrefix + stamp);
|
||||
Directory.CreateDirectory(bucket);
|
||||
|
||||
foreach (var name in KnownSegmentFiles)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The install-time deployment marker: <c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, an
|
||||
/// ISO-8601 UTC instant written by <c>--install</c> right after the runtime was handed off
|
||||
/// (writer: <c>Program.InstallMode</c> in the runtime project). It is the bootstrap script's
|
||||
/// re-entry lock and — since 2026-09-04 — the one instant the agent knows in UTC that also
|
||||
/// appears in the IME logs: the Intune Management Extension logs the bootstrap script's
|
||||
/// result a few hundred milliseconds later, which anchors the timezone of the pre-agent log
|
||||
/// era (<c>ImeLogEraPreScan</c>). Read-only here; never written from Core.
|
||||
/// </summary>
|
||||
public static class DeploymentMarker
|
||||
{
|
||||
public const string RegistryKeyPath = @"SOFTWARE\AutopilotMonitor";
|
||||
public const string RegistryValueName = "Deployed";
|
||||
|
||||
/// <summary>The marker's UTC instant, or <c>null</c> when absent or unparseable.</summary>
|
||||
public static DateTime? TryReadDeployedUtc()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(RegistryKeyPath))
|
||||
{
|
||||
var raw = key?.GetValue(RegistryValueName) as string;
|
||||
return Parse(raw);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure parse of the marker text (round-trip "O" format); exposed for tests.</summary>
|
||||
public static DateTime? Parse(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
|
||||
return null;
|
||||
return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (offsetMinutes.HasValue)
|
||||
data["sourceOffsetMinutes"] = offsetMinutes.Value.ToString(culture);
|
||||
|
||||
// Which era anchor the offset came from — forensics for the era-anchored origin.
|
||||
var anchorKind = _tracker.LastMatchedEraAnchorKind;
|
||||
if (!string.IsNullOrEmpty(anchorKind))
|
||||
data["sourceOffsetAnchor"] = anchorKind;
|
||||
|
||||
// Observational only — what the calibrator measured, which since the 2026-08-20
|
||||
// revert is deliberately NOT what was applied. Kept separate so the two can never
|
||||
// be read as the same thing.
|
||||
@@ -489,6 +494,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// The line anchored itself: read provably fresh, its own distance to the agent
|
||||
// clock (grid-rounded) was applied as the writer's offset. Era-safe per line.
|
||||
case CmTraceOffsetOrigin.LineAnchored: return "line-anchored";
|
||||
// A backlog line resolved through its writer era, anchored by the bootstrap
|
||||
// execution record against the install marker (ImeLogEraPreScan, 2026-09-04).
|
||||
case CmTraceOffsetOrigin.EraAnchored: return "era-anchored";
|
||||
// The line was not provably fresh (backlog, restart catch-up, replay), so this
|
||||
// process's own zone was assumed — correct only if the writer happens to share it.
|
||||
default: return "reader-zone-fallback";
|
||||
@@ -1620,6 +1628,15 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (app.AttemptNumber > 0) data["attemptNumber"] = app.AttemptNumber.ToString(culture);
|
||||
if (!string.IsNullOrEmpty(app.DetectionResult)) data["detectionResult"] = app.DetectionResult!;
|
||||
|
||||
// Installer outcome of the last attempt (IME-EXITCODE + IME-EXITCODE-CLASS). The class
|
||||
// is the admin's return-code mapping (Success/SoftReboot/HardReboot/Retry/Failed) and
|
||||
// the only evidence that an app asked for a reboot; the number says which code was
|
||||
// mapped (3010 vs 1641 vs custom). Both are known from the terminal lpExitCode lines
|
||||
// only, so they surface on the terminal events - completed, postponed, skipped and
|
||||
// failed alike - never on progress events.
|
||||
if (!string.IsNullOrEmpty(app.ExitCodeClass)) data["exitCodeClass"] = app.ExitCodeClass!;
|
||||
if (IsCompletedState(newState) && !string.IsNullOrEmpty(app.ExitCode)) data["exitCode"] = app.ExitCode!;
|
||||
|
||||
// Plan §5 Fix 4c — per-app install-lifecycle timing. StartedAt appears on the
|
||||
// first Downloading/Installing/InProgress event; CompletedAt + DurationSeconds
|
||||
// light up on the terminal event. Values are omitted when not yet known so the
|
||||
@@ -1636,7 +1653,6 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(app.ErrorPatternId)) data["errorPatternId"] = app.ErrorPatternId!;
|
||||
if (!string.IsNullOrEmpty(app.ErrorDetail)) data["errorDetail"] = app.ErrorDetail!;
|
||||
if (!string.IsNullOrEmpty(app.ErrorCode)) data["errorCode"] = app.ErrorCode!;
|
||||
if (!string.IsNullOrEmpty(app.ExitCode)) data["exitCode"] = app.ExitCode!;
|
||||
if (!string.IsNullOrEmpty(app.HResultFromWin32)) data["hresultFromWin32"] = app.HResultFromWin32!;
|
||||
|
||||
// Likely-stuck / detection-failure / install-failure classification: when the
|
||||
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+42
-17
@@ -1007,6 +1007,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1015,17 +1026,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
},
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName})."
|
||||
+ (result.Packaging != null && result.Packaging.Truncated
|
||||
? $" Truncated: {result.Packaging.SkippedFiles} file(s) skipped by the packaging caps."
|
||||
: string.Empty),
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
@@ -1033,6 +1038,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
var errorCode = result?.ErrorCode ?? "null-result";
|
||||
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}.");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result?.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1042,17 +1054,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics upload failed: {errorCode}.",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
},
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the packager's counters to a diagnostics event payload — counts only, never a
|
||||
/// file path (paths live in the ZIP's manifest and may carry user names). Absent when no
|
||||
/// package was built, so consumers treat the keys as optional.
|
||||
/// </summary>
|
||||
private static void AppendPackagingStats(Dictionary<string, object> data, DiagnosticsPackagingStats packaging)
|
||||
{
|
||||
if (packaging == null) return;
|
||||
data["truncated"] = packaging.Truncated;
|
||||
data["includedFiles"] = packaging.IncludedFiles;
|
||||
data["includedBytes"] = packaging.IncludedBytes;
|
||||
data["skippedFiles"] = packaging.SkippedFiles;
|
||||
data["skippedByReason"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.SkippedByReason ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
data["problemsByKind"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.ProblemsByKind ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
// On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the
|
||||
|
||||
+9
-5
@@ -395,12 +395,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
/// <summary>
|
||||
/// The batch body: a JSON array of <see cref="TelemetryItemDto"/> — the Shared class the
|
||||
/// ingest deserialises — with Newtonsoft defaults (PascalCase, ISO-8601 UTC, Kind by name).
|
||||
/// Internal so <c>TelemetryWireContractTests</c> can freeze the real output as a fixture.
|
||||
/// </summary>
|
||||
internal static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
{
|
||||
// Newtonsoft PascalCase default + TelemetryItemKind carries [StringEnumConverter] →
|
||||
// Backend routes by string "Event"/"Signal"/"DecisionTransition". Wire-format
|
||||
// matches Plan §4.x M5 contract (PascalCase, StringEnum, ISO-8601 UTC).
|
||||
return JsonConvert.SerializeObject(items, Formatting.None);
|
||||
var wire = new TelemetryItemDto[items.Count];
|
||||
for (var i = 0; i < items.Count; i++) wire[i] = items[i].ToWire();
|
||||
return JsonConvert.SerializeObject(wire, Formatting.None);
|
||||
}
|
||||
|
||||
private static byte[] CompressWithGzip(string text)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
@@ -106,6 +107,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <summary>Anzahl der bisher fehlgeschlagenen Upload-Versuche. Wird vom Orchestrator incrementiert.</summary>
|
||||
public int RetryCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The wire shape of this item. <see cref="TelemetryItemDto"/> (Shared) is the ONE class both
|
||||
/// ends serialise; this mapping is the only place the agent's validated model meets it.
|
||||
/// <c>TelemetryWireContractTests</c> proves every DTO property is populated here and
|
||||
/// freezes the resulting body under <c>tests/fixtures/telemetry-wire/</c> for the backend.
|
||||
/// </summary>
|
||||
public TelemetryItemDto ToWire() =>
|
||||
new TelemetryItemDto
|
||||
{
|
||||
Kind = Kind.ToString(),
|
||||
PartitionKey = PartitionKey,
|
||||
RowKey = RowKey,
|
||||
TelemetryItemId = TelemetryItemId,
|
||||
SessionTraceOrdinal = SessionTraceOrdinal,
|
||||
PayloadJson = PayloadJson,
|
||||
RequiresImmediateFlush = RequiresImmediateFlush,
|
||||
EnqueuedAtUtc = EnqueuedAtUtc,
|
||||
RetryCount = RetryCount,
|
||||
};
|
||||
|
||||
/// <summary>Copy-with-incremented <see cref="RetryCount"/>. Immutable-Erhalt (§2.3 L.3).</summary>
|
||||
public TelemetryItem WithRetryIncremented() =>
|
||||
new TelemetryItem(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user