mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1e7077d0a | |||
| d8c84f41c1 | |||
| 12b51bbc24 | |||
| a369ff99cd | |||
| 7a131af74d | |||
| e750cfe9dd | |||
| 3629728d93 | |||
| e521fd5cf4 | |||
| 0ed1e6f542 | |||
| 522911c5a7 | |||
| 0bfe1e327b | |||
| 2cf7216be5 | |||
| 587adc5ddf | |||
| 84bf2bc2fd | |||
| dcf1b1c227 | |||
| 6ebdfa41af | |||
| a2c4949473 | |||
| 81f4a55d88 | |||
| 4d3d3c56f4 | |||
| 41898d745a | |||
| c05ea4d63b | |||
| 68eb2da283 | |||
| ab0f27b5ab | |||
| 32d8151185 | |||
| f3749ccc16 | |||
| ca26cb4f7c | |||
| 8ceab7a064 | |||
| ae85dc325c | |||
| aeb2be6d64 | |||
| 0474223290 | |||
| 1bdb71be59 | |||
| ccf4f2e054 | |||
| d192a8e0fa | |||
| 06d92460ed | |||
| fc3900429c | |||
| 0ffcb2fde2 | |||
| a3089753d3 | |||
| 26cbc897c5 | |||
| c88629dcf2 | |||
| 517041a51c | |||
| eeaeb9ea7e | |||
| 3983402983 | |||
| 4deb5e6a04 | |||
| 5734eb576e | |||
| 7fbfea8f4e | |||
| e5f955354d | |||
| 65651dd599 |
+1
-1
Submodule .claude/commands updated: 4a98eb1da6...3f98090747
@@ -0,0 +1,139 @@
|
||||
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.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_blob:
|
||||
description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
name: Build Bootstrap MSI
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# The runner 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.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
- name: Read MSI version from Package.wxs
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') {
|
||||
Write-Error "Could not parse Version from Package.wxs"
|
||||
exit 1
|
||||
}
|
||||
$version = $Matches[1]
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version "5.*"
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
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"
|
||||
|
||||
- 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('?')
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
# stable name rotates in place behind the download alias. No legacy mirror — the
|
||||
# MSI channel never existed on the legacy account.
|
||||
function Send-Blob {
|
||||
param([string]$Local, [string]$BlobName)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
$headers = @{
|
||||
"x-ms-blob-type" = "BlockBlob"
|
||||
"Content-Type" = "application/octet-stream"
|
||||
"x-ms-blob-cache-control" = "no-cache"
|
||||
}
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$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
|
||||
Write-Host " uploaded $BlobName"
|
||||
}
|
||||
|
||||
# Versioned copy for provenance/rollback + stable customer-facing name.
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName "AutopilotMonitor-Bootstrap-v$version.msi"
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName $env:STABLE_MSI
|
||||
|
||||
# E2E: the Front Door route /agent/* must serve the fresh bytes on the public alias.
|
||||
- name: Verify download alias serves the MSI
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$localSize = (Get-Item $env:STABLE_MSI).Length
|
||||
$resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing
|
||||
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
|
||||
$remoteSize = [long]$resp.Headers['Content-Length'][0]
|
||||
if ($remoteSize -ne $localSize) {
|
||||
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -158,3 +158,9 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
|
||||
# Local IME binaries scratch used by the /ime-decompile skill (MSI + msiexec-
|
||||
# extracted payload per version, for grepping in binaries). Decompilates live in
|
||||
# the private ime-decompiles repo, MSIs in the ime-archive blob - this stays local.
|
||||
ime-files/
|
||||
|
||||
@@ -44,7 +44,7 @@ tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ every line; the spool fsyncs only items flagged `RequiresImmediateFlush`.
|
||||
| Event sequence | `State\event-sequence.json` | `{ LastAssignedSequence }` | atomic | none; survives WhiteGlove Part-2 resume |
|
||||
| Spool | `Spool\spool.jsonl` | JSONL (`TelemetryItem`/line) | only `RequiresImmediateFlush` items | none — never truncated, cursor skips uploaded items |
|
||||
| Upload cursor | `Spool\upload-cursor.json` | `{ LastUploadedItemId }` | atomic | idempotent, never regresses |
|
||||
| Agent trace log | `Logs\agent_YYYYMMDD[_NNN].log` | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Agent trace log | `Logs\agent.log` (segments `agent-NNN.log`) | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Crash dump + record | `Crashes\<stem>.dmp` + `.json` | MiniDump + JSON | n/a (process dying) | 5 files / 7 days |
|
||||
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
|
||||
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
|
||||
|
||||
@@ -146,7 +146,7 @@ snapshots) are executed by the `EffectRunner` — the reducer itself is pure. De
|
||||
| `\Agent\` | deployed binaries |
|
||||
| `\State\` | decision persistence (SignalLog, Journal, Snapshot), `enrollment-complete.marker`, `final-status.json`, self-update state, `.quarantine/`, `.part1-<ts>/` |
|
||||
| `\Spool\` | telemetry spool (offline queue) |
|
||||
| `\Logs\` | agent trace log, `crash_*.log`, `ime_pattern_matches.log` |
|
||||
| `\Logs\` | agent trace log (`agent.log`), `crash-*.log`, `ime-pattern-matches.log` |
|
||||
| `\Config\` | `remote-config.json` cache |
|
||||
| `\Agent-Update\`, `\Updates\` | self-update staging/download |
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
type: concept
|
||||
title: Registry App-State Second Pillar
|
||||
description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
resource: agent
|
||||
tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Why a second pillar
|
||||
|
||||
The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log
|
||||
wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline
|
||||
(killing 9 patterns silently), reworded the post-remediation line, changed the
|
||||
report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The
|
||||
registry, by contrast, is IME's *internal product contract* — the ESP page and Intune
|
||||
reporting consume it — and cannot drift casually.
|
||||
|
||||
Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative
|
||||
(downloads, retries, exit codes, scripts — the timeline). The registry is the stable
|
||||
truth for terminal state. Divergence between the two IS the alarm that log patterns
|
||||
drifted — turning silent data loss into a Warning event.
|
||||
|
||||
## Observed surfaces
|
||||
|
||||
All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified
|
||||
against decompiled IME source:
|
||||
|
||||
| Surface | Content |
|
||||
|---|---|
|
||||
| `Win32Apps\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
|
||||
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
|
||||
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). |
|
||||
|
||||
## Mechanism
|
||||
|
||||
`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate —
|
||||
precedent `EspPolicyProviderStallHost`):
|
||||
|
||||
- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces;
|
||||
changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both
|
||||
the reconciliation-settle evaluator and the poll-only fallback when the watcher
|
||||
fails to arm (`collector_degraded`, once).
|
||||
- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges
|
||||
only, so every tick re-reads the surfaces and diffs against the previous snapshot.
|
||||
- **Silent baseline**: the state present at agent start is captured without events.
|
||||
Win32Apps keys survive re-enrollments — replaying them would be the registry twin
|
||||
of the historic-IME-replay bug. Only apps that *change during the session* are
|
||||
reported or judged.
|
||||
- **`registry_app_state`** (Info): emitted per real field change
|
||||
(enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase),
|
||||
capped at 200/session (cap announced once as Warning).
|
||||
- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a
|
||||
changed entry has been terminal (EnforcementState 1000er=success / 5000er=error,
|
||||
or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the
|
||||
log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence
|
||||
reasons: `registry_error_log_installed`, `registry_success_log_error`,
|
||||
`app_unknown_to_log_tracking` (only when the tracker is actively tracking apps —
|
||||
an idle tracker is not judgeable).
|
||||
- **Pure observability**: nothing feeds the DecisionEngine (same rule as
|
||||
oobe-state observability).
|
||||
|
||||
## WDP reuse (Paket 3)
|
||||
|
||||
The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the
|
||||
intended vehicle for WDP live progress: watch
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent`
|
||||
(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the
|
||||
RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an
|
||||
Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`,
|
||||
channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written
|
||||
on WDP devices too — the pillar works on both rails unchanged.
|
||||
|
||||
# Examples
|
||||
|
||||
Timeline evidence of a drifted pattern set: apps install fine (registry says
|
||||
EnforcementState 1000) but the timeline shows no `app_install_completed` — each app
|
||||
raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`.
|
||||
Operator response: run `/ime-pattern-validate` against the session, fix patterns,
|
||||
backend-deploy (no agent release needed for pattern fixes).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs`
|
||||
- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog)
|
||||
- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\<version>\decompiled`
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: concept
|
||||
title: WDP Scenario Gates & Completion Backstop
|
||||
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
|
||||
resource: agent
|
||||
tags: [wdp, device-preparation, decision-engine, collectors, completion]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
|
||||
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
|
||||
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
|
||||
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
|
||||
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
|
||||
woken by every WDP progress write, and — worst — no completion path when Hello never
|
||||
resolved (park until the 360-min watchdog).
|
||||
|
||||
## Detection: deterministic vs. fallback
|
||||
|
||||
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
|
||||
**deterministic WDP**:
|
||||
|
||||
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
|
||||
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
|
||||
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
|
||||
and the `DevicePreparationExecutionContextRule` constant.
|
||||
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
|
||||
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
|
||||
so every gate below deliberately ignores them: **gates require the deterministic
|
||||
marker and fail toward Classic behavior.**
|
||||
|
||||
## Agent-side gates (all keyed to the deterministic marker)
|
||||
|
||||
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
|
||||
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
|
||||
`enrollment_type_detected` still fires. (WDP devices DO carry an
|
||||
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
|
||||
— Windows runs the ZTD download regardless — so the probe fired for real before.)
|
||||
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
|
||||
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
|
||||
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
|
||||
Entra joins.
|
||||
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
|
||||
`EnrollmentStatusTracking` key) is not created on WDP.
|
||||
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
|
||||
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
|
||||
writes progress, so it woke on every write only to read three ESP category values
|
||||
that never exist there.
|
||||
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
|
||||
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
|
||||
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
|
||||
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
|
||||
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
|
||||
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
|
||||
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
|
||||
diagnostics during a stall).
|
||||
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
|
||||
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
|
||||
run during provisioning); per-app data still travels via `app_tracking_summary` and
|
||||
final-status.json.
|
||||
|
||||
## DecisionCore: WDP completion without ESP facts
|
||||
|
||||
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
|
||||
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
|
||||
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
|
||||
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
|
||||
defence in depth.
|
||||
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
|
||||
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
|
||||
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
|
||||
policy the fast-path now completes at desktop arrival.
|
||||
* **Prerequisites**: `completion_waiting` never lists
|
||||
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
|
||||
`skipUserEsp` exemption).
|
||||
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
|
||||
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
|
||||
pending. On fire it mirrors the HelloSafety timeout: synthetic
|
||||
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
|
||||
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
|
||||
swallowed by the post-terminal dispatch guard.
|
||||
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
|
||||
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
|
||||
as `session_parked_without_deadline`.
|
||||
|
||||
# Examples
|
||||
|
||||
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
|
||||
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
|
||||
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
|
||||
entries — all structurally meaningless on WDP. After the gates, the same session shape
|
||||
emits none of these; completion rode Hello-skipped + desktop as before.
|
||||
|
||||
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
|
||||
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
|
||||
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
|
||||
`registry_app_state` as the primary WDP app source.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
|
||||
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
|
||||
@@ -58,6 +58,21 @@ timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## Quality flags (AttributionVersion 2)
|
||||
|
||||
Duration-critical flags (`ClockSkewDropped`, `PartialObservation`,
|
||||
`WhiteGloveAnchorsIncomplete`, `PriorEnrollmentResidue`) exclude a breakdown from fleet
|
||||
segment statistics with a disclosed count; the blocking-set flags (`BlockingSetUnknown`,
|
||||
`BlockingSetTruncated`) only limit per-app evidence and never gate fleet stats.
|
||||
|
||||
`PriorEnrollmentResidue` (v2) fires on `historic_ime_replay_detected`: the IME log on disk
|
||||
predates the enrollment, so the device was re-enrolled without a wipe — pre-installed apps
|
||||
complete as instant detections and pull the phase anchors ahead of the real ESP page
|
||||
(session f475e697: AccountSetup anchor 6.5 min early, 457 s misattributed to
|
||||
`identity_hello`). `registry_app_baseline` successes are deliberately NOT a trigger — they
|
||||
are by-design normal for Windows Device Preparation (DPP Batch-1 apps install before the
|
||||
agent exists) and would starve that class's aggregates.
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
@@ -117,7 +132,7 @@ gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 2).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
|
||||
@@ -25,6 +25,8 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-18
|
||||
|
||||
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
|
||||
|
||||
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
|
||||
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
|
||||
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
|
||||
|
||||
## 2026-08-17
|
||||
|
||||
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -51,7 +51,7 @@
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
|
||||
{ "signal": "aad_placeholder_user_detected", "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.",
|
||||
"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.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
@@ -70,9 +70,23 @@
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Address VPN and Windows Hello dependencies",
|
||||
"steps": [
|
||||
"If a VPN is required to reach a domain controller, it must be established before or at the logon screen (machine tunnel / pre-logon tunnel). A user-context tunnel that starts after sign-in is too late for the hybrid PRT flow.",
|
||||
"If you deploy Windows Hello for Business to hybrid-joined devices, use cloud Kerberos trust — it removes the on-premises certificate/key-trust dependencies that commonly stall Hello on hybrid devices.",
|
||||
"If you do not use Windows Hello, disable it explicitly via a device-scoped Intune policy instead of leaving it unconfigured — the enrollment then resolves the Hello step deterministically instead of waiting for a wizard that never comes.",
|
||||
"Avoid feature/quality updates mid-enrollment where possible — an OS update with forced reboots during enrollment aggravates this pattern. Consider a short Windows Update deferral covering the enrollment window.",
|
||||
"For hybrid deployments, pre-provisioning (White Glove) materially shrinks the user-facing phase: the device phase runs on a known-good technician network, and the user phase reduces to sign-in, user ESP, and Hello."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — stuck in Account Setup (no user affinity)",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-stuck-in-account-setup"
|
||||
},
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
|
||||
+16
-2
@@ -2526,7 +2526,7 @@
|
||||
"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.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -2587,7 +2587,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
@@ -2606,9 +2606,23 @@
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Address VPN and Windows Hello dependencies",
|
||||
"steps": [
|
||||
"If a VPN is required to reach a domain controller, it must be established before or at the logon screen (machine tunnel / pre-logon tunnel). A user-context tunnel that starts after sign-in is too late for the hybrid PRT flow.",
|
||||
"If you deploy Windows Hello for Business to hybrid-joined devices, use cloud Kerberos trust — it removes the on-premises certificate/key-trust dependencies that commonly stall Hello on hybrid devices.",
|
||||
"If you do not use Windows Hello, disable it explicitly via a device-scoped Intune policy instead of leaving it unconfigured — the enrollment then resolves the Hello step deterministically instead of waiting for a wizard that never comes.",
|
||||
"Avoid feature/quality updates mid-enrollment where possible — an OS update with forced reboots during enrollment aggravates this pattern. Consider a short Windows Update deferral covering the enrollment window.",
|
||||
"For hybrid deployments, pre-provisioning (White Glove) materially shrinks the user-facing phase: the device phase runs on a known-good technician network, and the user phase reduces to sign-in, user ESP, and Hello."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — stuck in Account Setup (no user affinity)",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-stuck-in-account-setup"
|
||||
},
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
|
||||
+134
-42
@@ -4,12 +4,23 @@
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -139,6 +150,14 @@
|
||||
"description": "Captures the install attempt number for the current app. IME logs 'Execute retry 0' for the first attempt and increments on each retry. Reported as attempt+1 so retry 0 becomes AttemptNumber=1 (human-friendly: first attempt).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DECRYPT-FAILED",
|
||||
"category": "currentPhase",
|
||||
@@ -150,9 +169,9 @@
|
||||
{
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -168,15 +187,15 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -217,9 +236,9 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -236,9 +255,9 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -266,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -293,6 +312,14 @@
|
||||
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-PHASE",
|
||||
"category": "always",
|
||||
@@ -304,9 +331,9 @@
|
||||
{
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -320,6 +347,33 @@
|
||||
"description": "Captures the installer exit code (lpExitCode) for the current app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
@@ -377,25 +431,25 @@
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -422,6 +476,17 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SESSION-CHANGE",
|
||||
"category": "always",
|
||||
@@ -435,24 +500,24 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
|
||||
"enabled": true
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-2",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
|
||||
"enabled": true
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-3",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current.",
|
||||
"enabled": true
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-4",
|
||||
@@ -478,24 +543,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
|
||||
"enabled": true
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-FILTERS",
|
||||
@@ -529,8 +594,8 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STARTED",
|
||||
@@ -540,6 +605,17 @@
|
||||
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SUBGRAPH",
|
||||
"category": "currentPhase",
|
||||
@@ -548,6 +624,14 @@
|
||||
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-TOKEN-SUCCESS",
|
||||
"category": "always",
|
||||
@@ -564,24 +648,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-USER-SESSION-COMPLETED",
|
||||
@@ -607,6 +691,14 @@
|
||||
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-INSTALLING-1",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTED-INSTALLED-1",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
|
||||
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-DOWNLOAD",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
|
||||
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "checkTo": "true" },
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-CLEARED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-GRS-SKIP",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
|
||||
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
|
||||
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-NOT-APPLICABLE-4",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
|
||||
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
|
||||
"enabled": true
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
|
||||
"enabled": true
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current.",
|
||||
"enabled": true
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
|
||||
"enabled": true
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-TOKEN-FAILURE",
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
"cancelStuckAndSetCurrent",
|
||||
"imeSessionChange",
|
||||
"imeImpersonation",
|
||||
"imeTokenFailure",
|
||||
"enrollmentCompleted",
|
||||
"updateDoTelemetry",
|
||||
"scriptStarted",
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-08-18 v2.4 Renamed log file bootstrap_agent.log -> bootstrap-script.log
|
||||
(unified kebab-case log naming across the agent log directory).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -126,13 +128,13 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.3"
|
||||
$ScriptVersion = "2.4"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
|
||||
+35
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEspEvidenceAtAll_SuppressesTheEvent()
|
||||
{
|
||||
// afee7ae0 (WDP / plain Entra join): FirstSync has no values and the ESP tracking
|
||||
// key does not exist — an event saying only "SkipUser=unknown, SkipDevice=unknown"
|
||||
// is noise and must not be emitted (mirrors the orchestrator bootstrap's null-gate).
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-nullgate", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: null, skipDevice: null,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => default))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
|
||||
Assert.Empty(EspConfigEvents(ingress));
|
||||
|
||||
// A later refresh where real evidence appeared must still emit normally.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice);
|
||||
}
|
||||
Assert.Single(EspConfigEvents(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
|
||||
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
EspSkipConfigurationProbe.TestOverride = _ => (null, null);
|
||||
EspSkipConfigurationProbe.FullTestOverride = _ => EspFirstSyncSnapshot.Empty;
|
||||
// Same reasoning for the IME-install-source probe: a dev machine with real
|
||||
// EnterpriseDesktopAppManagement CSP state would nondeterministically enrich
|
||||
// ime_agent_version payloads in adapter tests.
|
||||
ImeMsiInstallSourceProbe.TestOverride = (_, _) => ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
/// </summary>
|
||||
public sealed class AgentLoggerTests
|
||||
{
|
||||
private static string TodayBaseName => $"agent_{DateTime.Now:yyyyMMdd}";
|
||||
private const string BaseName = "agent";
|
||||
|
||||
[Fact]
|
||||
public void Log_writes_to_unsuffixed_base_file_when_dir_is_empty()
|
||||
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
|
||||
logger.Info("hello");
|
||||
|
||||
var basePath = Path.Combine(tmp.Path, TodayBaseName + ".log");
|
||||
var basePath = Path.Combine(tmp.Path, BaseName + ".log");
|
||||
Assert.True(File.Exists(basePath), $"expected base file at {basePath}");
|
||||
Assert.Contains("hello", File.ReadAllText(basePath));
|
||||
}
|
||||
@@ -39,17 +39,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
|
||||
// Each line ~80 bytes after timestamp/severity envelope; 30 lines clears 1 KB
|
||||
// even at the smallest payload. The first lines fill the base file; once the
|
||||
// FileInfo.Length read sees >= 1024 the next call rotates to _002.
|
||||
// FileInfo.Length read sees >= 1024 the next call rotates to -002.
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
logger.Info($"line-{i:D4}-padded-with-some-extra-context-to-cross-1KB-quickly");
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(tmp.Path, TodayBaseName + "*.log").OrderBy(s => s).ToArray();
|
||||
var files = Directory.GetFiles(tmp.Path, BaseName + "*.log").OrderBy(s => s).ToArray();
|
||||
Assert.True(files.Length >= 2, $"expected at least 2 segments, got: {string.Join(",", files.Select(Path.GetFileName))}");
|
||||
|
||||
// The unsuffixed file is the first segment; rotated segments use _002 onward.
|
||||
var rotated = files.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p).EndsWith("_002"));
|
||||
// The unsuffixed file is the first segment; rotated segments use -002 onward.
|
||||
var rotated = files.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p).EndsWith("-002"));
|
||||
Assert.NotNull(rotated);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
logger.Info($"pad-{i:D4}-content-content-content-content-content-content");
|
||||
}
|
||||
|
||||
var rotatedPath = Path.Combine(tmp.Path, TodayBaseName + "_002.log");
|
||||
Assert.True(File.Exists(rotatedPath), "expected agent_YYYYMMDD_002.log to exist");
|
||||
var rotatedPath = Path.Combine(tmp.Path, BaseName + "-002.log");
|
||||
Assert.True(File.Exists(rotatedPath), "expected agent-002.log to exist");
|
||||
var first = File.ReadAllLines(rotatedPath).FirstOrDefault();
|
||||
Assert.NotNull(first);
|
||||
Assert.Contains("AgentLogger: rotated", first);
|
||||
@@ -75,23 +75,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
public void Log_continues_existing_rotation_on_restart_without_overwriting()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
// Pre-seed: looks like an earlier process already rotated up to _003.
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"), "older-base\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log"), "older-002\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"), "older-003\n");
|
||||
// Pre-seed: looks like an earlier process already rotated up to -003.
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + ".log"), "older-base\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + "-002.log"), "older-002\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + "-003.log"), "older-003\n");
|
||||
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
logger.Info("after-restart");
|
||||
|
||||
// New entries must land in the highest existing segment (_003), not in a fresh
|
||||
// New entries must land in the highest existing segment (-003), not in a fresh
|
||||
// base file. Older content in _003 must be preserved.
|
||||
var resumed = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"));
|
||||
var resumed = File.ReadAllText(Path.Combine(tmp.Path, BaseName + "-003.log"));
|
||||
Assert.Contains("older-003", resumed);
|
||||
Assert.Contains("after-restart", resumed);
|
||||
|
||||
// Older segments are left alone.
|
||||
Assert.Equal("older-base\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log")));
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
|
||||
Assert.Equal("older-base\n", File.ReadAllText(Path.Combine(tmp.Path, BaseName + ".log")));
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, BaseName + "-002.log")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
// as plain ASCII, while genuine non-ASCII payload stays untouched.
|
||||
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
|
||||
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, BaseName + ".log"));
|
||||
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
|
||||
// Hold an exclusive lock on today's file to force AppendAllText to throw.
|
||||
var lockedPath = Path.Combine(tmp.Path, TodayBaseName + ".log");
|
||||
// Hold an exclusive lock on the log file to force AppendAllText to throw.
|
||||
var lockedPath = Path.Combine(tmp.Path, BaseName + ".log");
|
||||
File.WriteAllText(lockedPath, ""); // ensure exists
|
||||
using var holder = new FileStream(lockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather-rules-debug.log");
|
||||
|
||||
[Fact]
|
||||
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
|
||||
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
for (int i = 0; i < 20; i++)
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
|
||||
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather-rules-debug.old.log");
|
||||
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
|
||||
Assert.True(File.Exists(LogPath));
|
||||
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
public GatherRuleExecutorDebugLogTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant", _ => { }, logger,
|
||||
debugLogPath: _debugLogPath)
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
Directory.CreateDirectory(_outsideUsers);
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
_context = new GatherRuleContext(
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
|
||||
+36
@@ -56,6 +56,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(1, loaded.PeakDo.DownloadMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_then_load_roundtrips_terminal_metadata()
|
||||
{
|
||||
// Session bc803955 — CompletedAtUtc/VersionReached feed the summary dialog's
|
||||
// synthetic Office row (duration + version).
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
var completedAt = new DateTime(2026, 6, 8, 12, 3, 22, DateTimeKind.Utc);
|
||||
|
||||
sut.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
CompletedAtUtc = completedAt,
|
||||
VersionReached = "16.0.20228.20190",
|
||||
});
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(completedAt, loaded!.CompletedAtUtc);
|
||||
Assert.Equal("16.0.20228.20190", loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_tolerates_state_files_from_older_agents_without_terminal_metadata()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
// Pre-terminal-metadata wire format (verbatim shape from a live 2.0.1404 session).
|
||||
File.WriteAllText(sut.StateFilePath,
|
||||
"{\"State\":\"Completed\",\"StartedAtUtc\":\"2026-08-18T07:56:16.8208141Z\",\"StartedTrigger\":\"registry\"}");
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(OfficeInstallStateData.StateCompleted, loaded!.State);
|
||||
Assert.Null(loaded.CompletedAtUtc);
|
||||
Assert.Null(loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_overwrites_a_previous_state()
|
||||
{
|
||||
|
||||
+70
-2
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Directory.CreateDirectory(SpoolFolder);
|
||||
}
|
||||
|
||||
public DiagnosticsPackageService Build()
|
||||
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
|
||||
{
|
||||
// BackendApiClient is required by the public ctor but BuildArchiveBytes
|
||||
// never touches it — construct with a throwaway HttpClient. Tests only
|
||||
@@ -61,8 +61,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
bootstrapToken: null,
|
||||
agentVersion: "0.0.0",
|
||||
logger: Logger);
|
||||
var cfg = Cfg();
|
||||
mutateConfig?.Invoke(cfg);
|
||||
return new DiagnosticsPackageService(
|
||||
Cfg(),
|
||||
cfg,
|
||||
Logger,
|
||||
apiClient,
|
||||
agentLogFolderOverride: LogsTmp.Path,
|
||||
@@ -131,6 +133,72 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Contains("AgentMarkers/agent_clean_exit.marker", entries);
|
||||
}
|
||||
|
||||
// ============================================================ package manifest ====
|
||||
// The agent-log snapshot is zipped BEFORE packaging finishes, so packaging problems
|
||||
// are invisible in the uploaded archive's own log (field case: missing evtx in
|
||||
// sessions a11102f4/3ae7528b). The manifest travels INSIDE the ZIP instead.
|
||||
|
||||
private static string? ReadZipEntryText(byte[] bytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(bytes);
|
||||
using var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Read);
|
||||
var entry = archive.GetEntry(entryName);
|
||||
if (entry == null) return null;
|
||||
using var reader = new StreamReader(entry.Open());
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_always_writes_package_manifest_with_added_entries()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains("ADDED: AgentState/snapshot.json", manifest);
|
||||
// Empty standard folders (e.g. ImeLogs) surface as NO MATCH lines — the silent
|
||||
// nothing-found case is exactly what the manifest exists to make visible.
|
||||
Assert.Contains("NO MATCH:", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_guard_blocked_additional_path()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var bytes = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }))
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {blockedPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_manifest_records_users_path_blocked_even_in_unrestricted()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// The test temp dir lives under C:\Users — blocked by the always-on privacy guard
|
||||
// even in unrestricted mode. The manifest must say so instead of staying silent.
|
||||
var configuredPath = Path.Combine(rig.DataFolder, "does-not-exist.evtx");
|
||||
|
||||
var bytes = rig.Build(cfg =>
|
||||
{
|
||||
cfg.UnrestrictedMode = true;
|
||||
cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = configuredPath });
|
||||
})
|
||||
.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Contains($"BLOCKED (path guard): {configuredPath}", manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config()
|
||||
{
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Selection/normalization logic of <see cref="ImeMsiInstallSourceProbe"/> — the registry
|
||||
/// walk itself is not unit-testable (live HKLM), so tests cover the pure pieces:
|
||||
/// <c>SelectBestMatch</c>, <c>VersionsEqual</c>, <c>IsImeMsiUrl</c>.
|
||||
/// </summary>
|
||||
public class ImeMsiInstallSourceProbeTests
|
||||
{
|
||||
private const string ImeProductCode = "{6A7DFC50-0395-4E1E-BF84-ED1404E72051}";
|
||||
private const string ImeUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
|
||||
|
||||
private static ImeMsiInstallSourceProbe.MsiEntry Entry(
|
||||
string productCode = ImeProductCode,
|
||||
string version = "1.104.102.0",
|
||||
string url = ImeUrl)
|
||||
=> new ImeMsiInstallSourceProbe.MsiEntry(productCode, version, url);
|
||||
|
||||
// -- SelectBestMatch -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_exact_product_version_wins()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "9.9.9.9", url: "https://x/Other.msi"),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
Assert.Equal("1.104.102.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_normalized_version_matches_trailing_zero()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry(version: "1.104.102") },
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.True(result.MatchedByVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_mismatch_falls_back_to_ime_msi_filename()
|
||||
{
|
||||
// IME self-updated between MSI enforcement and log line — versions drift, but the
|
||||
// enforced IntuneWindowsAgent.msi entry is still the install source.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
Entry(version: "1.103.100.0"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.False(result.MatchedByVersion);
|
||||
Assert.Equal("1.103.100.0", result.ProductVersion);
|
||||
Assert.Equal(ImeUrl, result.DownloadUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_version_match_beats_filename_match_on_other_entry()
|
||||
{
|
||||
// A stale IntuneWindowsAgent.msi row must not shadow the entry whose
|
||||
// ProductVersion equals the running build.
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{STALE}", version: "1.103.100.0"),
|
||||
Entry(productCode: "{CURRENT}", version: "1.104.102.0", url: "https://y/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.MatchedByVersion);
|
||||
Assert.Equal("{CURRENT}", result.ProductCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_no_plausible_entry_returns_empty()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.False(result.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_empty_or_null_candidates_return_empty()
|
||||
{
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>(), "1.104.102.0").HasData);
|
||||
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
null, "1.104.102.0").HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectBestMatch_null_registry_values_do_not_throw()
|
||||
{
|
||||
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry>
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry("{PARTIAL}", null, null),
|
||||
Entry(),
|
||||
},
|
||||
"1.104.102.0");
|
||||
|
||||
Assert.True(result.HasData);
|
||||
Assert.Equal(ImeProductCode, result.ProductCode);
|
||||
}
|
||||
|
||||
// -- VersionsEqual ---------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("1.104.102.0", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.102", true)]
|
||||
[InlineData(" 1.104.102.0 ", "1.104.102.0", true)]
|
||||
[InlineData("1.104.102.0", "1.104.103.0", false)]
|
||||
[InlineData("1.104.102.0", "1.104.102.1", false)]
|
||||
[InlineData("1.104", "1.104.102.0", false)]
|
||||
[InlineData("abc", "abc", true)] // non-numeric: whole-string equality
|
||||
[InlineData("abc", "abd", false)] // non-numeric components never fuzzy-match
|
||||
[InlineData(null, "1.0", false)]
|
||||
[InlineData("1.0", null, false)]
|
||||
[InlineData("", "", false)]
|
||||
public void VersionsEqual_cases(string? a, string? b, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.VersionsEqual(a, b));
|
||||
}
|
||||
|
||||
// -- IsImeMsiUrl -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(ImeUrl, true)]
|
||||
[InlineData("https://x/intunewindowsagent.MSI", true)] // case-insensitive
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi?sv=abc&sig=def", true)] // query ignored
|
||||
[InlineData("IntuneWindowsAgent.msi", true)] // bare filename
|
||||
[InlineData("https://x/NotTheAgent.msi", false)]
|
||||
[InlineData("https://x/IntuneWindowsAgent.msi.bak", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsImeMsiUrl_cases(string? url, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ImeMsiInstallSourceProbe.IsImeMsiUrl(url));
|
||||
}
|
||||
|
||||
// -- ScopedOverride ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ScopedOverride_restores_previous_override_on_dispose()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces Empty; a nested scope must restore it.
|
||||
Func<string, AgentLogger, ImeMsiInstallSource> before =
|
||||
ImeMsiInstallSourceProbe.TestOverride;
|
||||
using (new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry() }, "1.104.102.0")))
|
||||
{
|
||||
Assert.True(ImeMsiInstallSourceProbe.Read("1.104.102.0").HasData);
|
||||
}
|
||||
Assert.Same(before, ImeMsiInstallSourceProbe.TestOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
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 Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry second pillar (audit 2026-08-17): pure parsing/diff logic plus the
|
||||
/// tick flow (baseline silence, state-change emission, reconciliation dwell)
|
||||
/// driven through the snapshot override — no live registry.
|
||||
/// </summary>
|
||||
public sealed class ImeRegistryAppStateObserverTests
|
||||
{
|
||||
private const string App1 = "11111111-1111-1111-1111-111111111111";
|
||||
private const string App2 = "22222222-2222-2222-2222-222222222222";
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── pure helpers ────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111_1", "11111111-1111-1111-1111-111111111111")]
|
||||
[InlineData("11111111-1111-1111-1111-111111111111", "11111111-1111-1111-1111-111111111111")]
|
||||
[InlineData("GRS", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("not-a-guid_1", null)]
|
||||
public void ExtractAppId_parses_app_keys_and_rejects_non_app_keys(string keyName, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ExtractAppId(keyName));
|
||||
|
||||
[Fact]
|
||||
public void ParseEnforcementStateMessage_reads_state_and_error()
|
||||
{
|
||||
var (state, error) = ImeRegistryAppStateObserver.ParseEnforcementStateMessage(
|
||||
"{\"EnforcementState\":1000,\"ErrorCode\":0}");
|
||||
Assert.Equal(1000, state);
|
||||
Assert.Equal(0L, error);
|
||||
|
||||
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("not json"));
|
||||
Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("[]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, "success")]
|
||||
[InlineData(1004, "success")]
|
||||
[InlineData(2009, "inProgress")]
|
||||
[InlineData(3000, "requirementsNotMet")]
|
||||
[InlineData(5003, "error")]
|
||||
[InlineData(6001, "notAttempted")]
|
||||
[InlineData(42, "unknown")]
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1999, "installed")]
|
||||
[InlineData(3000, "failed")]
|
||||
[InlineData(2000, "notApplicable")]
|
||||
[InlineData(2999, "notApplicable")]
|
||||
[InlineData(0, null)]
|
||||
[InlineData(999, null)]
|
||||
[InlineData(3001, null)]
|
||||
[InlineData(4000, null)]
|
||||
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
|
||||
|
||||
[Fact]
|
||||
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
|
||||
{
|
||||
// Enforcement state alone decides when terminal.
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 5003)));
|
||||
// Non-terminal enforcement bands defer to the StatusService pillar.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(
|
||||
Entry(enforcementState: 2009, statusServiceStatus: 3000)));
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 3000)));
|
||||
// requirementsNotMet / notApplicable / empty entries are not judgeable.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_reports_each_field_and_ignores_espPhase_casing()
|
||||
{
|
||||
var prev = Entry(enforcementState: 2009, errorCode: null, exitCode: null,
|
||||
statusServiceStatus: null, espTracked: false, espPhase: "DevicePreparation");
|
||||
var next = Entry(enforcementState: 1000, errorCode: 0, exitCode: 0,
|
||||
statusServiceStatus: 1000, espTracked: true, espPhase: "devicepreparation");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(prev, next);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "enforcementState", "errorCode", "exitCode", "statusServiceStatus", "espTracked" },
|
||||
changed);
|
||||
// Case-only espPhase difference is not a change; identity diffs to empty.
|
||||
Assert.DoesNotContain("espPhase", changed);
|
||||
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_null_previous_reports_populated_fields_of_new_entry()
|
||||
{
|
||||
var fresh = Entry(enforcementState: 2000, espTracked: true, espPhase: "AccountSetup");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(null, fresh);
|
||||
|
||||
Assert.Contains("enforcementState", changed);
|
||||
Assert.Contains("espTracked", changed);
|
||||
Assert.Contains("espPhase", changed);
|
||||
// Fields that are null on both sides are not "changes" for a brand-new entry.
|
||||
Assert.DoesNotContain("errorCode", changed);
|
||||
Assert.DoesNotContain("exitCode", changed);
|
||||
Assert.DoesNotContain("statusServiceStatus", changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffSnapshots_reports_only_changed_fields()
|
||||
{
|
||||
var prev = Snapshot((App1, 2009, null));
|
||||
var next = Snapshot((App1, 1000, 0), (App2, 2000, null));
|
||||
|
||||
var changes = ImeRegistryAppStateObserver.DiffSnapshots(prev, next);
|
||||
|
||||
Assert.Equal(2, changes.Count);
|
||||
var app1Change = changes.Single(c => c.Entry.AppId == App1);
|
||||
Assert.Contains("enforcementState", app1Change.ChangedFields);
|
||||
Assert.Contains("errorCode", app1Change.ChangedFields);
|
||||
Assert.False(app1Change.IsNew);
|
||||
Assert.True(changes.Single(c => c.Entry.AppId == App2).IsNew);
|
||||
|
||||
Assert.Empty(ImeRegistryAppStateObserver.DiffSnapshots(next, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDivergent_rules()
|
||||
{
|
||||
var installed = PackageState(App1, AppInstallationState.Installed);
|
||||
var errored = PackageState(App1, AppInstallationState.Error);
|
||||
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("error", installed, true, out var r1));
|
||||
Assert.Equal("registry_error_log_installed", r1);
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", errored, true, out var r2));
|
||||
Assert.Equal("registry_success_log_error", r2);
|
||||
Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", null, true, out var r3));
|
||||
Assert.Equal("app_unknown_to_log_tracking", r3);
|
||||
// Tracker idle (no apps at all) — not judgeable, no false alarm.
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", null, false, out _));
|
||||
// Agreement.
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", installed, true, out _));
|
||||
Assert.False(ImeRegistryAppStateObserver.IsDivergent("error", errored, true, out _));
|
||||
}
|
||||
|
||||
// ── tick flow ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Baseline_is_silent_then_changes_emit_state_events()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
|
||||
current = Snapshot((App1, 1000, 0));
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var evt = Assert.Single(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
Assert.Equal(App1, evt.Payload!["appId"]);
|
||||
Assert.Equal("1000", evt.Payload["enforcementState"]);
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonEmpty_baseline_emits_one_summary_with_counts()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 1000, 0), (App2, 5003, 101));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
observer.Tick("periodic"); // summary is one-shot, not per tick
|
||||
}
|
||||
|
||||
var summary = Assert.Single(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
Assert.Equal("2", summary.Payload!["totalApps"]);
|
||||
Assert.Equal("1", summary.Payload["successCount"]);
|
||||
Assert.Equal("1", summary.Payload["errorCount"]);
|
||||
// The baseline stays silent on the diff rail.
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_baseline_emits_no_summary()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = new ImeRegistrySnapshot();
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unchanged_snapshot_emits_nothing()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
observer.Tick("periodic");
|
||||
observer.Tick("periodic");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconciliation_fires_once_after_settle_delay_on_divergence()
|
||||
{
|
||||
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
|
||||
var current = Snapshot((App1, 2000, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = Snapshot((App1, 5003, 101)); // registry error, log says Installed
|
||||
observer.Tick("registry_change");
|
||||
|
||||
// Before the settle delay: no reconciliation yet.
|
||||
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
|
||||
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
|
||||
observer.Tick("periodic");
|
||||
observer.Tick("periodic"); // second pass must not re-emit
|
||||
}
|
||||
|
||||
var rec = Assert.Single(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
Assert.Equal("error", rec.Payload!["registryOutcome"]);
|
||||
Assert.Equal("registry_error_log_installed", rec.Payload["reason"]);
|
||||
Assert.Equal("Installed", rec.Payload["logState"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconciliation_skips_agreeing_and_baseline_only_apps()
|
||||
{
|
||||
var tracker = new List<AppPackageState> { PackageState(App1, AppInstallationState.Installed) };
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: tracker);
|
||||
|
||||
// App2 is terminal in the BASELINE (pre-existing from an earlier enrollment) and
|
||||
// never changes — it must never be judged. App1 changes and agrees with the log.
|
||||
var current = Snapshot((App1, 2000, null), (App2, 5000, 1));
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = Snapshot((App1, 1000, 0), (App2, 5000, 1));
|
||||
observer.Tick("registry_change");
|
||||
|
||||
clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1));
|
||||
observer.Tick("periodic");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_event_cap_emits_single_warning_then_suppresses()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
|
||||
var baseline = new ImeRegistrySnapshot();
|
||||
var flooded = new ImeRegistrySnapshot();
|
||||
for (var i = 0; i < ImeRegistryAppStateObserver.MaxStateEventsPerSession + 25; i++)
|
||||
{
|
||||
var appId = Guid.NewGuid().ToString("D");
|
||||
baseline.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 2000;
|
||||
flooded.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 1000;
|
||||
}
|
||||
|
||||
var current = baseline;
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
current = flooded;
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var events = Events(sink, SharedEventTypes.RegistryAppState);
|
||||
// Cap payload-carrying events + exactly one cap-notice Warning.
|
||||
Assert.Equal(ImeRegistryAppStateObserver.MaxStateEventsPerSession + 1, events.Count);
|
||||
Assert.Single(events, e => e.Payload != null
|
||||
&& e.Payload.TryGetValue(SignalPayloadKeys.Message, out var m)
|
||||
&& m.Contains("cap reached"));
|
||||
}
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static (FakeSignalIngressSink sink, VirtualClock clock, ImeRegistryAppStateObserver observer)
|
||||
CreateObserver(IReadOnlyList<AppPackageState>? trackerApps)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var observer = new ImeRegistryAppStateObserver(
|
||||
post,
|
||||
logger: null,
|
||||
clock: clock,
|
||||
trackerStateProbe: trackerApps == null ? null : () => trackerApps);
|
||||
return (sink, clock, observer);
|
||||
}
|
||||
|
||||
private static AppRegistryEntry Entry(
|
||||
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
|
||||
{
|
||||
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
|
||||
entry.EnforcementState = enforcementState;
|
||||
entry.ErrorCode = errorCode;
|
||||
entry.ExitCode = exitCode;
|
||||
entry.StatusServiceStatus = statusServiceStatus;
|
||||
entry.EspTracked = espTracked;
|
||||
entry.EspPhase = espPhase;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps)
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
foreach (var (appId, state, error) in apps)
|
||||
{
|
||||
var entry = snapshot.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId);
|
||||
entry.EnforcementState = state;
|
||||
entry.ErrorCode = error;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static AppPackageState PackageState(string id, AppInstallationState state)
|
||||
{
|
||||
var pkg = new AppPackageState(id, listPos: 0);
|
||||
if (state == AppInstallationState.Installed)
|
||||
{
|
||||
// Route through Installing first — a bare Installed would trip the
|
||||
// inverse-detection auto-downgrade heuristic (Installed w/o activity -> Skipped).
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
}
|
||||
pkg.UpdateState(state);
|
||||
return pkg;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Events(FakeSignalIngressSink sink, string eventType) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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 Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session afee7ae0 (2026-08-18) — Device Preparation structural-noise suppression.
|
||||
/// A nine-minute WDP session shipped 900+ occurrences of WIL error 1005 plus 400+
|
||||
/// duplicate-workload 417/418 records and "Autopilot policy not found" 100s — all
|
||||
/// structural on WDP (no deployment profile exists; the BootstrapperAgent's SLDM
|
||||
/// progress poll produces the 417/418 chatter). On WDP those IDs are suppressed
|
||||
/// entirely (<see cref="ModernDeploymentTracker.DevicePreparationNoiseEventIds"/>);
|
||||
/// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep
|
||||
/// flowing, and Classic sessions keep the harmless-downgrade + rollup path.
|
||||
/// </summary>
|
||||
public sealed class ModernDeploymentTrackerDevicePrepTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 8, 18, 9, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public ModernDeploymentTrackerDevicePrepTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ModernDeploymentTracker BuildTracker(bool isDevicePreparation) =>
|
||||
new ModernDeploymentTracker(
|
||||
sessionId: "sess-wdp-noise",
|
||||
tenantId: "tenant-wdp-noise",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: _logger,
|
||||
backfillEnabled: false,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
private void Process(ModernDeploymentTracker tracker, int eventId, int level, string shortName, string channel)
|
||||
{
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: level,
|
||||
levelDisplayName: level == 1 ? "Critical" : level == 2 ? "Error" : "Warning",
|
||||
providerName: "ModernDeployment-Diagnostics-Provider",
|
||||
timeCreatedUtc: At,
|
||||
formattedDescription: $"EventID {eventId} record.",
|
||||
shortName: shortName,
|
||||
channelName: channel,
|
||||
isBackfill: false);
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> PostedModernDeployment() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("modern_deployment", StringComparison.Ordinal)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 3, "Autopilot")] // policy not found — no profile exists on WDP
|
||||
[InlineData(417, 3, "Autopilot")] // duplicate workload — SLDM poll chatter
|
||||
[InlineData(418, 3, "Autopilot")] // duplicate batch — SLDM poll chatter
|
||||
[InlineData(1005, 2, "ManagementService")] // WIL error storm
|
||||
public void WdpNoiseIds_AreFullySuppressed_OnDevicePreparation(int eventId, int level, string shortName)
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
var channel = shortName == "Autopilot"
|
||||
? ModernDeploymentTracker.AutopilotChannel
|
||||
: ModernDeploymentTracker.ManagementChannel;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Process(tracker, eventId, level, shortName, channel);
|
||||
}
|
||||
|
||||
Assert.Empty(PostedModernDeployment());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenuineWdpWarning_408_KeepsFlowing_OnDevicePreparation()
|
||||
{
|
||||
// 408 "Autopilot Provisioning reported a warning" carries the real WDP session
|
||||
// context — it is NOT in the noise set and must keep flowing.
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 408, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_warning", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CriticalRecords_AreNeverSuppressed_EvenForNoiseIds()
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 1005, level: 1, shortName: "ManagementService", channel: ModernDeploymentTracker.ManagementChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_error", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicSessions_KeepTheHarmlessDowngradePath_ForTheSameIds()
|
||||
{
|
||||
// Classic regression guard: without the WDP flag EventID 100 still flows through
|
||||
// the harmless-downgrade path (individual Debug emissions up to the rollup limit).
|
||||
var tracker = BuildTracker(isDevicePreparation: false);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
Process(tracker, 100, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
}
|
||||
|
||||
Assert.Equal(2, PostedModernDeployment().Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -131,4 +131,117 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
trySchtasks: null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the restricted-token launch ordering introduced 2026-08-17: MDM LOB MSI installs
|
||||
/// (EnterpriseDesktopAppManagement CSP → msiexec chain) carry a SYSTEM token with
|
||||
/// SeTimeZonePrivilege / SeSystemEnvironmentPrivilege REMOVED, and WMI Win32_Process.Create
|
||||
/// duplicates the caller token onto the runtime — so on a stripped token the Scheduled Task
|
||||
/// (fresh full SYSTEM token) must launch first and WMI is only the degraded fallback. These
|
||||
/// tests fail loudly if a refactor silently restores WMI-first for the stripped-token case
|
||||
/// or drops the process verification after schtasks /Run.
|
||||
/// </summary>
|
||||
public sealed class InstallModeRestrictedTokenLaunchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Schtasks_success_with_verified_pid_returns_schtasks_and_never_touches_wmi()
|
||||
{
|
||||
var wmiInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 4321,
|
||||
tryWmi: () => { wmiInvocations++; return new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 9999); });
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Schtasks, result.Method);
|
||||
Assert.Equal(4321, result.Pid);
|
||||
Assert.Equal(0, result.SchtasksExitCode);
|
||||
Assert.Equal(0, wmiInvocations);
|
||||
Assert.Contains("full SYSTEM token", result.Diagnostic);
|
||||
Assert.Contains("PID=4321", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_queued_but_no_process_appears_falls_back_to_wmi()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 0,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 777));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(777, result.Pid);
|
||||
Assert.Contains("no runtime process appeared", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schtasks_failure_skips_verification_and_falls_back_to_wmi()
|
||||
{
|
||||
var verifyInvocations = 0;
|
||||
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => { verifyInvocations++; return 0; },
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
|
||||
Assert.Equal(0, verifyInvocations);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_fallback_diagnostic_warns_that_the_runtime_stays_degraded()
|
||||
{
|
||||
// Support line: a restricted-token WMI launch means timezone auto-set / firmware
|
||||
// reads will fail for this runtime instance — the log must say so explicitly.
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
|
||||
|
||||
Assert.Contains("RESTRICTED TOKEN", result.Diagnostic);
|
||||
Assert.Contains("timezone", result.Diagnostic, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_paths_fail_returns_deferred_with_both_codes_captured()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(2u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
Assert.Equal(0, result.Pid);
|
||||
Assert.Equal(2u, result.WmiReturnValue);
|
||||
Assert.Equal(1, result.SchtasksExitCode);
|
||||
Assert.Contains("BootTrigger", result.Diagnostic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wmi_returnvalue_zero_but_pid_zero_does_not_count_as_success()
|
||||
{
|
||||
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => 1,
|
||||
verifyRuntimePid: () => 0,
|
||||
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 0));
|
||||
|
||||
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_delegates_are_rejected()
|
||||
{
|
||||
Func<int> zero = () => 0;
|
||||
Func<AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt> wmi =
|
||||
() => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 1);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(null!, zero, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, null!, wmi));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, zero, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
using var tmp = new TempDirectory();
|
||||
var logDir = Path.Combine(tmp.Path, "logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var crashPath = Path.Combine(logDir, "crash_20260421_100000.log");
|
||||
var crashPath = Path.Combine(logDir, "crash-20260421-100000.log");
|
||||
File.WriteAllText(crashPath, "[2026-04-21T10:00:00Z] FATAL: InvalidOperationException: simulated crash");
|
||||
|
||||
var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, logDir);
|
||||
@@ -312,7 +312,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
AutopilotMonitor.Agent.V2.Program.WriteCrashLog(logDir, new InvalidOperationException("boom"));
|
||||
|
||||
var files = Directory.GetFiles(logDir, "crash_*.log");
|
||||
var files = Directory.GetFiles(logDir, "crash-*.log");
|
||||
Assert.Single(files);
|
||||
var content = File.ReadAllText(files[0]);
|
||||
Assert.Contains("FATAL:", content);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using AutopilotMonitor.Agent.V2;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment-stable pins for the token probe. Whether SeTimeZonePrivilege itself is
|
||||
/// held depends on the test runner's token, so it is NOT asserted here — only the two
|
||||
/// contracts that never vary: a privilege present in every Windows token reports held,
|
||||
/// and an unknown privilege name fails soft to "held" (the probe must never flip the
|
||||
/// launch ordering because of its own defect).
|
||||
/// </summary>
|
||||
public sealed class TokenPrivilegeProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SeChangeNotifyPrivilege_is_held_in_every_token()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeChangeNotifyPrivilege"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_privilege_name_fails_soft_to_held()
|
||||
{
|
||||
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeDefinitelyNotARealPrivilege"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
@@ -461,6 +462,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("IME Agent version: 1.101.109.0", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_emits_warning_event_with_error_code()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeTokenFailureFromTest("3399614467",
|
||||
"Failed to get AAD token. len = 0 using client id x and resource id y, errorCode = 3399614467");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
|
||||
Assert.Equal("3399614467", info.Payload["errorCode"]);
|
||||
Assert.Contains("errorCode=3399614467", info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.Contains("Failed to get AAD token", info.Payload["logLine"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_grace_window_emits_only_without_token_success()
|
||||
{
|
||||
// "Failed to get AAD token" appears routinely during the device phase (no user
|
||||
// token yet) — the Warning must fire only when NO success follows for the whole
|
||||
// grace window.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Failure resolved by a token success: never emitted, not even after the window.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
|
||||
f.Tracker.NoteTokenSuccessForTest();
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromMinutes(1));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
// Unresolved failure: silent before the window, one Warning after it.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromMinutes(5));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromHours(1));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
// Same code again: re-armed window expires but the per-code dedup suppresses.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line c");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromHours(3));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_empty_error_code_reports_unknown()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeTokenFailureFromTest("", null!);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
Assert.Equal("unknown", info.Payload!["errorCode"]);
|
||||
Assert.False(info.Payload.ContainsKey("logLine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_null_or_empty_is_skipped()
|
||||
{
|
||||
@@ -487,6 +546,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_enriches_with_csp_install_source_when_probe_matches()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((v, _) =>
|
||||
ImeMsiInstallSourceProbe.SelectBestMatch(
|
||||
new[]
|
||||
{
|
||||
new ImeMsiInstallSourceProbe.MsiEntry(
|
||||
"{6A7DFC50-0395-4E1E-BF84-ED1404E72051}",
|
||||
"1.104.102.0",
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi"),
|
||||
},
|
||||
v));
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.Equal("1.104.102.0", info.Payload!["agentVersion"]);
|
||||
Assert.Equal(
|
||||
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
|
||||
info.Payload["msiDownloadUrl"]);
|
||||
Assert.Equal("{6A7DFC50-0395-4E1E-BF84-ED1404E72051}", info.Payload["msiProductCode"]);
|
||||
Assert.Equal("1.104.102.0", info.Payload["msiProductVersion"]);
|
||||
Assert.Equal("productVersion", info.Payload["msiMatchedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_without_csp_match_omits_msi_keys()
|
||||
{
|
||||
// Assembly default (TestAssemblyInit) forces the probe to Empty.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
|
||||
Assert.False(info.Payload!.ContainsKey("msiDownloadUrl"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductCode"));
|
||||
Assert.False(info.Payload.ContainsKey("msiProductVersion"));
|
||||
Assert.False(info.Payload.ContainsKey("msiMatchedBy"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeAgentVersion_probe_result_is_cached_per_version()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
int probeCalls = 0;
|
||||
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
|
||||
{
|
||||
probeCalls++;
|
||||
return ImeMsiInstallSource.Empty;
|
||||
});
|
||||
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
|
||||
adapter.TriggerImeAgentVersionFromTest("1.105.0.0");
|
||||
|
||||
Assert.Equal(2, probeCalls); // one read per distinct version, not per event
|
||||
Assert.Equal(3, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields()
|
||||
{
|
||||
|
||||
+111
-1
@@ -165,6 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
public IReadOnlyCollection<string> StarvedAlreadyReportedOverride { get; set; } = Array.Empty<string>();
|
||||
public List<string> TerminationActionLog { get; } = new List<string>();
|
||||
public TimeSpan SpoolDrainPeriodOverride { get; set; } = TimeSpan.Zero;
|
||||
// Session bc803955 — DevicePrep app-wave settle wait. Zero (default) disables the
|
||||
// wait so the existing timing-sensitive tests stay untouched; the dedicated settle
|
||||
// tests opt in with a real budget.
|
||||
public TimeSpan DevicePrepAppSettleWaitOverride { get; set; } = TimeSpan.Zero;
|
||||
// WG Part-1 straggler-ordering fix — the production wiring passes
|
||||
// orchestrator.StopCollectorHosts, whose collectors emit their one-shot
|
||||
// stop-time stragglers (network_bandwidth_estimate, …) through Post during
|
||||
@@ -210,7 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
stopPeripheralCollectors: StopPeripheralCollectorsHook,
|
||||
// Zero out the timing ceremony for tests — production paths are covered by
|
||||
// the dedicated V1-parity tests below which opt back in via their own Rig.
|
||||
lateEventGracePeriod: TimeSpan.Zero);
|
||||
lateEventGracePeriod: TimeSpan.Zero,
|
||||
devicePrepAppSettleWait: DevicePrepAppSettleWaitOverride);
|
||||
}
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
@@ -317,6 +322,91 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false");
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — DevicePrep app-wave settle wait ----
|
||||
|
||||
private static DecisionState DevicePrepState(SessionStage stage) =>
|
||||
new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = stage,
|
||||
ScenarioProfile = new EnrollmentScenarioProfile(
|
||||
mode: EnrollmentMode.DevicePreparation,
|
||||
joinMode: EnrollmentJoinMode.Unknown,
|
||||
espConfig: EspConfig.Unknown,
|
||||
preProvisioningSide: PreProvisioningSide.None,
|
||||
confidence: ProfileConfidence.Medium,
|
||||
evidenceOrdinal: 1,
|
||||
reason: "test"),
|
||||
}.Build();
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_catches_late_app_wave_for_dialog()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Simulate the IME user-context sync landing AFTER termination handling began
|
||||
// (AppWorkload.log flush latency) — the wave arrives 500ms in, well before the
|
||||
// 10s budget; the settle loop must pick it up and the dialog must render it.
|
||||
var waveThread = new Thread(() =>
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
var app = new AppPackageState("app-late", 0);
|
||||
app.UpdateState(AppInstallationState.Downloading);
|
||||
TestHelpers.SetTargeted(app, AppTargeted.User);
|
||||
rig.Packages.Add(app);
|
||||
});
|
||||
waveThread.Start();
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
waveThread.Join();
|
||||
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 1", statusJson);
|
||||
// Early exit: the wave arrived at ~500ms — the handler must not sit out the full 10s.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"settle wait should exit early once packages arrive (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_is_bounded_when_no_apps_arrive()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromMilliseconds(600);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
// Budget elapsed (>=600ms) and the dialog still rendered — just without app data.
|
||||
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(600),
|
||||
$"bounded wait should consume its budget when no data arrives (took {sw.Elapsed.TotalMilliseconds:0}ms)");
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 0", statusJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_non_devicePrep_does_not_wait_even_with_empty_packages()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// Classic/unknown mode + empty package list — the settle wait must not engage.
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"non-DevicePrep termination must not settle-wait (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal()
|
||||
{
|
||||
@@ -1407,6 +1497,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StarvedApps_skipped_on_device_preparation()
|
||||
{
|
||||
// afee7ae0: WDP has no ESP apps gate — post-desktop installs are the designed
|
||||
// order, so the sweep would report every pending app as noise (10+ events on a
|
||||
// normal session). The per-app data still travels via app_tracking_summary.
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.StarvedAppsOverride = new[]
|
||||
{
|
||||
BuildStarvedApp("app-pending-1", "Contoso Backgrounds"),
|
||||
BuildStarvedApp("app-pending-2", "Contoso Tools"),
|
||||
};
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteActiveInstalls_skipped_on_effect_infrastructure_failure()
|
||||
{
|
||||
|
||||
+117
@@ -1,8 +1,10 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -508,6 +510,121 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Null(status.SignalTimestamps);
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — synthetic Office row from the C2R detector's persisted state ----
|
||||
|
||||
private static FinalStatus BuildWithOffice(OfficeInstallStateData? officeState, IReadOnlyList<AppPackageState>? packages = null) =>
|
||||
FinalStatusBuilder.Build(StateWith(SessionStage.Completed),
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
|
||||
packageStates: packages, agentStartTimeUtc: StartUtc, officeState: officeState);
|
||||
|
||||
[Fact]
|
||||
public void Build_office_completed_adds_device_row_with_duration_and_counts()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(4).AddSeconds(22),
|
||||
VersionReached = "16.0.20228.20190",
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps (16.0.20228.20190)", row.AppName);
|
||||
Assert.Equal("Installed", row.State);
|
||||
Assert.True(row.IsCompleted);
|
||||
Assert.False(row.IsError);
|
||||
Assert.Equal(202, row.DurationSeconds);
|
||||
Assert.Equal(office.StartedAtUtc.Value.ToString("o"), row.StartedAt);
|
||||
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(1, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(0, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_active_row_is_installing_without_completion()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateActive,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps", row.AppName);
|
||||
Assert.Equal("Installing", row.State);
|
||||
Assert.False(row.IsCompleted);
|
||||
Assert.Null(row.DurationSeconds);
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(0, status.AppSummary.CompletedApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_failed_row_counts_device_error()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateFailed,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(2),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Error", row.State);
|
||||
Assert.True(row.IsError);
|
||||
Assert.True(row.IsCompleted); // Error is terminal — mirrors IsCompleted()
|
||||
Assert.Equal(1, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.DeviceErrors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OfficeInstallStateData.StatePreinstalled)]
|
||||
[InlineData("garbage")]
|
||||
[InlineData(null)]
|
||||
public void Build_office_preinstalled_or_unknown_adds_no_row(string? state)
|
||||
{
|
||||
var office = state == null ? null : new OfficeInstallStateData { State = state };
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
Assert.Empty(status.PackageStatesByPhase);
|
||||
Assert.Equal(0, status.AppSummary.TotalApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_row_is_prepended_before_ime_device_apps()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var list = new AppPackageStateList(logger);
|
||||
var imeApp = new AppPackageState("app-1", 0);
|
||||
imeApp.UpdateState(AppInstallationState.Installed);
|
||||
TestHelpers.SetTargeted(imeApp, AppTargeted.Device);
|
||||
list.Add(imeApp);
|
||||
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc,
|
||||
CompletedAtUtc = StartUtc.AddMinutes(3),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office, list);
|
||||
|
||||
Assert.Equal(2, status.PackageStatesByPhase["Device"].Count);
|
||||
Assert.StartsWith("Microsoft 365 Apps", status.PackageStatesByPhase["Device"][0].AppName);
|
||||
Assert.Equal(2, status.AppSummary.TotalApps);
|
||||
Assert.Equal(2, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(2, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.</summary>
|
||||
|
||||
@@ -221,6 +221,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, System.Threading.Volatile.Read(ref signalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_tracks_enqueue_and_shrinks_to_zero_when_all_uploaded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c"));
|
||||
|
||||
// PendingBytes must equal the full file size while nothing is uploaded yet
|
||||
// (every line written by Enqueue is still pending).
|
||||
Assert.Equal(spool.SpoolFileSizeBytes, spool.PendingBytes);
|
||||
|
||||
spool.MarkUploaded(2);
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
// File keeps its append-only size — the two measures MUST diverge here,
|
||||
// otherwise the pressure trigger regresses to the file-size false positive.
|
||||
Assert.True(spool.SpoolFileSizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_after_partial_upload_equals_remaining_tail_line_bytes()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c-longer-rowkey-for-distinct-length"));
|
||||
|
||||
spool.MarkUploaded(1);
|
||||
|
||||
// Expected = on-disk byte length of the last line incl. its trailing \n.
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "spool.jsonl"), Encoding.UTF8);
|
||||
var expected = Encoding.UTF8.GetByteCount(lines[2]) + 1;
|
||||
Assert.Equal(expected, spool.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_is_rehydrated_on_restart_from_pending_tail_only()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
s1.Enqueue(EventDraft("b"));
|
||||
s1.Enqueue(EventDraft("c"));
|
||||
s1.MarkUploaded(1);
|
||||
var expectedPendingBytes = s1.PendingBytes;
|
||||
Assert.True(expectedPendingBytes > 0);
|
||||
|
||||
// Restart: rehydrated PendingBytes must match the pre-restart value exactly
|
||||
// (uploaded prefix excluded), not the full file size.
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(expectedPendingBytes, s2.PendingBytes);
|
||||
Assert.True(s2.PendingBytes < s2.SpoolFileSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_rehydrate_ignores_corrupt_tail_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
var cleanPendingBytes = s1.PendingBytes;
|
||||
|
||||
File.AppendAllText(
|
||||
Path.Combine(tmp.Path, "spool.jsonl"),
|
||||
"{\"Kind\":\"Event\",\"PartitionKey\":\"corrupt-no-",
|
||||
Encoding.UTF8);
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(cleanPendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_without_RequiresImmediateFlush_does_not_raise_ImmediateFlushRequested()
|
||||
{
|
||||
|
||||
@@ -34,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
|
||||
private readonly long _maxFileSizeBytes;
|
||||
private readonly string _logDirectory;
|
||||
private readonly string _logFileBaseName; // "agent_20260425"
|
||||
private string _logFilePath; // mutable — rotates to "_002", "_003" ...
|
||||
private int _rotationSuffix; // 0 = base file, 2+ = "_002" etc.
|
||||
private readonly string _logFileBaseName; // "agent"
|
||||
private string _logFilePath; // mutable — rotates to "-002", "-003" ...
|
||||
private int _rotationSuffix; // 0 = base file, 2+ = "-002" etc.
|
||||
|
||||
/// <summary>
|
||||
/// When true, log entries are also written to Console.Out (same format as the log file).
|
||||
@@ -55,14 +55,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
}
|
||||
|
||||
_logFileBaseName = $"agent_{DateTime.Now:yyyyMMdd}";
|
||||
// One canonical name, no date: the agent only lives for the duration of an
|
||||
// enrollment, and a date-based split rotates at local midnight — which can fall
|
||||
// mid-enrollment (local time may still be on the OOBE default timezone). Size
|
||||
// rotation below bounds pathological growth. Legacy `agent_YYYYMMDD*.log` files
|
||||
// from a pre-rename agent are ignored by the suffix probe and left in place.
|
||||
_logFileBaseName = "agent";
|
||||
_rotationSuffix = ProbeNextRotationSuffix(logDirectory, _logFileBaseName);
|
||||
_logFilePath = BuildLogFilePath();
|
||||
}
|
||||
|
||||
// Picks up the highest existing rotation suffix on the same day so a process restart
|
||||
// doesn't overwrite previous segments. Returns 0 when no log file exists yet — the
|
||||
// first segment uses the unsuffixed name `agent_YYYYMMDD.log`.
|
||||
// Picks up the highest existing rotation suffix so a process restart doesn't
|
||||
// overwrite previous segments. Returns 0 when no log file exists yet — the
|
||||
// first segment uses the unsuffixed name `agent.log`.
|
||||
private static int ProbeNextRotationSuffix(string dir, string baseName)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
@@ -80,7 +85,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
continue;
|
||||
}
|
||||
if (name.Length > baseName.Length + 1 &&
|
||||
name.StartsWith(baseName + "_", StringComparison.OrdinalIgnoreCase) &&
|
||||
name.StartsWith(baseName + "-", StringComparison.OrdinalIgnoreCase) &&
|
||||
int.TryParse(name.Substring(baseName.Length + 1), out var suffix) &&
|
||||
suffix > max)
|
||||
{
|
||||
@@ -97,7 +102,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
var name = _rotationSuffix < 2
|
||||
? $"{_logFileBaseName}.log"
|
||||
: $"{_logFileBaseName}_{_rotationSuffix:D3}.log";
|
||||
: $"{_logFileBaseName}-{_rotationSuffix:D3}.log";
|
||||
return Path.Combine(_logDirectory, name);
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -25,6 +25,71 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
// "Failed to get AAD token" is logged ROUTINELY during the device phase (no user
|
||||
// token exists yet — field-confirmed: the line appears en masse in every enrollment,
|
||||
// Oliver 2026-08-18). A failure is therefore only meaningful when NO token success
|
||||
// follows: the failure is held pending, any IME-TOKEN-SUCCESS match clears it, and
|
||||
// the Warning fires only after the grace window elapses without a success. Genuine
|
||||
// auth outages (WAM broken, conditional access, network) run long enough to hit it.
|
||||
// Not persisted across restarts — failures keep arriving, the window re-arms.
|
||||
internal static readonly TimeSpan TokenFailureGraceWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
private sealed class PendingTokenFailure
|
||||
{
|
||||
public string Code = "unknown";
|
||||
public string Excerpt = string.Empty;
|
||||
public DateTime FirstFailureUtc;
|
||||
}
|
||||
|
||||
private PendingTokenFailure _pendingTokenFailure;
|
||||
// Dedup per distinct error code so a persistent outage yields one Warning total.
|
||||
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private void HandleImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
if (_pendingTokenFailure != null) return; // earliest unresolved failure wins
|
||||
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
var excerpt = message ?? string.Empty;
|
||||
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
|
||||
|
||||
_pendingTokenFailure = new PendingTokenFailure
|
||||
{
|
||||
Code = code,
|
||||
Excerpt = excerpt,
|
||||
// Source-line time so a late-starting agent replaying an ongoing outage
|
||||
// ages the window correctly; a success later in the same log still clears
|
||||
// before the expiry check runs (lines are drained first, expiry after).
|
||||
FirstFailureUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private void ClearPendingTokenFailure()
|
||||
{
|
||||
if (_pendingTokenFailure == null) return;
|
||||
_logger.Debug($"ImeLogTracker: token success observed — pending token failure (errorCode={_pendingTokenFailure.Code}) cleared");
|
||||
_pendingTokenFailure = null;
|
||||
}
|
||||
|
||||
/// <summary>Called once per poll cycle after the log drain (and testable directly).</summary>
|
||||
internal void CheckPendingTokenFailure(DateTime nowUtc)
|
||||
{
|
||||
var pending = _pendingTokenFailure;
|
||||
if (pending == null) return;
|
||||
if (nowUtc - pending.FirstFailureUtc < TokenFailureGraceWindow) return;
|
||||
|
||||
_pendingTokenFailure = null; // next failure line re-arms a fresh window
|
||||
if (!_reportedTokenFailureCodes.Add(pending.Code)) return;
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failing for >= {TokenFailureGraceWindow.TotalMinutes:0} min without a success (errorCode={pending.Code})");
|
||||
OnImeTokenFailure?.Invoke(pending.Code, pending.Excerpt);
|
||||
}
|
||||
|
||||
// Test seams: drive the pending/clear/expiry cycle without the regex pipeline.
|
||||
internal void HandleImeTokenFailureForTest(string errorCode, string message)
|
||||
=> HandleImeTokenFailure(errorCode, message);
|
||||
internal void NoteTokenSuccessForTest() => ClearPendingTokenFailure();
|
||||
|
||||
private void HandleImeImpersonation(string user)
|
||||
{
|
||||
var current = string.IsNullOrEmpty(user) ? "(unknown)" : user;
|
||||
|
||||
+15
@@ -214,6 +214,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
"cancelstuckandsetcurrent", "updatedotelemetry",
|
||||
// Not app-mutating, but a stale token-failure replayed from a previous
|
||||
// enrollment's log would emit a misleading Warning for THIS session.
|
||||
"imetokenfailure",
|
||||
};
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
@@ -241,6 +244,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
|
||||
// A token success resolves any pending token failure (grace-window model, see
|
||||
// HandleImeTokenFailure). Matched on our own stable pattern ID — the line also
|
||||
// drives espPhaseDetected, whose handler must stay unaware of token semantics.
|
||||
// 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();
|
||||
|
||||
try
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
@@ -296,6 +307,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
OnImeAgentVersion?.Invoke(version);
|
||||
break;
|
||||
|
||||
case "imetokenfailure":
|
||||
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
|
||||
break;
|
||||
|
||||
case "imeimpersonation":
|
||||
// PR3-A2: dedup. The same user triggers ~24 identical lines per session;
|
||||
// log on first/changed user, otherwise count and emit a single rollup
|
||||
|
||||
@@ -119,6 +119,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"IntuneManagementExtension-????????-??????.log",
|
||||
"AppWorkload.log",
|
||||
"AppWorkload-????????-??????.log",
|
||||
// Enforcement decision engine (V3 ActionProcessor). IME routes every
|
||||
// [Win32App][ActionProcessor] line here (app.config TraceSource mapping) —
|
||||
// NO-ACTION / NOT-TARGETED classifications and the GRS "will not be
|
||||
// enforced" skips are ONLY visible in this file (audit 2026-08-17).
|
||||
"AppActionProcessor.log",
|
||||
"AppActionProcessor-????????-??????.log",
|
||||
// PowerShell script tracking
|
||||
"AgentExecutor.log",
|
||||
"AgentExecutor-????????-??????.log",
|
||||
@@ -194,6 +200,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
// 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; }
|
||||
// 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
|
||||
@@ -598,6 +607,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// line never arrived within the grace period. Runs on this same loop
|
||||
// thread (no locking) so it observes the buffer right after parsing.
|
||||
FlushStalePlatformScriptResults(DateTime.UtcNow);
|
||||
|
||||
// Token-failure grace window: fires only when the failure stayed
|
||||
// unresolved (no IME-TOKEN-SUCCESS) for the whole window. Runs after
|
||||
// the drain so a success later in the same batch clears first.
|
||||
CheckPendingTokenFailure(DateTime.UtcNow);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
|
||||
+29
-12
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _modernDeploymentBackfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int[] _modernDeploymentHarmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
|
||||
private readonly Func<bool> _accountSetupActivityProbe;
|
||||
private readonly Func<bool> _userEspAppsSettledProbe;
|
||||
@@ -194,7 +195,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
|
||||
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
|
||||
/// NOT a WhiteGlove indicator on Win11 25H2+ — Windows writes it in every enrollment
|
||||
/// (see <see cref="ProvisioningStatusTracker.HasSaveWhiteGloveSuccessResult"/>). Observability only.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
|
||||
|
||||
@@ -210,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int modernDeploymentBackfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] modernDeploymentHarmlessEventIds = null,
|
||||
bool isDevicePreparation = false,
|
||||
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
|
||||
Func<bool> accountSetupActivityProbe = null,
|
||||
Func<bool> userEspAppsSettledProbe = null,
|
||||
@@ -227,6 +230,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
// Test seam: allow injection of a fake skip-config reader; defaults to the real
|
||||
// registry probe so production never has to think about this parameter.
|
||||
_skipConfigProbe = skipConfigProbe ?? (() => EspSkipConfigurationProbe.Read(_logger));
|
||||
@@ -347,16 +351,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_shellCoreTracker.HelloWizardStarted += OnHelloWizardStarted;
|
||||
_shellCoreTracker.Start();
|
||||
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
// WDP gate (afee7ae0, 2026-08-18): Device Preparation writes its BootstrapperAgent
|
||||
// progress under the very AutopilotSettings key this tracker subtree-watches, so on
|
||||
// WDP the watcher woke up on every provisioning write only to read three ESP
|
||||
// category values that never exist there. The ESP categories are the tracker's
|
||||
// ONLY subject — skip it entirely. All consumers already null-guard the tracker.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EspAndHelloTracker: Device Preparation session — ProvisioningStatusTracker (ESP categories) not started");
|
||||
}
|
||||
|
||||
if (_modernDeploymentWatcherEnabled)
|
||||
{
|
||||
@@ -369,7 +385,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillEnabled,
|
||||
_modernDeploymentBackfillLookbackMinutes,
|
||||
_stateDirectory,
|
||||
_modernDeploymentHarmlessEventIds);
|
||||
_modernDeploymentHarmlessEventIds,
|
||||
_isDevicePreparation);
|
||||
_modernDeploymentTracker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 2026-08-17 — resolves where the running IME build was downloaded from.
|
||||
/// The IME is installed through the EnterpriseDesktopAppManagement CSP, which documents
|
||||
/// every MSI it enforces under
|
||||
/// <c>HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\<SID>\MSI\{ProductCode}</c>
|
||||
/// (values: <c>CurrentDownloadUrl</c>, <c>ProductVersion</c>, <c>ProductCode</c>, …).
|
||||
/// Given the IME version parsed from the IME log, this probe finds the matching MSI entry
|
||||
/// and returns its <c>CurrentDownloadUrl</c> so the <c>ime_agent_version</c> event can say
|
||||
/// which CDN/AFD endpoint delivered exactly that build (e.g.
|
||||
/// <c>imeswdb-afd-secondary.manage.microsoft.com</c>).
|
||||
/// <para>
|
||||
/// Matching: first by normalized <c>ProductVersion</c> equality (trailing <c>.0</c>
|
||||
/// components ignored), then — because the version line in the log and the enforced MSI can
|
||||
/// drift apart around an IME self-update — by download-URL filename
|
||||
/// <c>IntuneWindowsAgent.msi</c>. The match kind is surfaced so consumers know whether the
|
||||
/// URL is version-authoritative.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
|
||||
/// <see cref="ImeMsiInstallSource.Empty"/>; a missing CSP key (no MDM-enforced MSIs yet) is
|
||||
/// not an error.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImeMsiInstallSourceProbe
|
||||
{
|
||||
internal const string CspRootKeyPath = @"SOFTWARE\Microsoft\EnterpriseDesktopAppManagement";
|
||||
|
||||
private const string ImeMsiFileName = "IntuneWindowsAgent.msi";
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — when non-null, <see cref="Read"/> delegates to this func instead of
|
||||
/// touching the live registry. Use <see cref="ScopedOverride"/> to guarantee cleanup.
|
||||
/// </summary>
|
||||
internal static Func<string, AgentLogger, ImeMsiInstallSource> TestOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the CSP MSI entry for <paramref name="imeVersion"/> (the version string parsed
|
||||
/// from the IME log). Returns <see cref="ImeMsiInstallSource.Empty"/> when the CSP key is
|
||||
/// missing, unreadable, or holds no plausible entry.
|
||||
/// </summary>
|
||||
/// <param name="imeVersion">IME version from the log, e.g. <c>1.104.102.0</c>.</param>
|
||||
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
|
||||
public static ImeMsiInstallSource Read(string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe(imeVersion, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var candidates = new List<MsiEntry>();
|
||||
using (var rootKey = Registry.LocalMachine.OpenSubKey(CspRootKeyPath))
|
||||
{
|
||||
if (rootKey == null)
|
||||
{
|
||||
logger?.Debug("ImeMsiInstallSourceProbe: EnterpriseDesktopAppManagement registry key not found");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
// Layout: <SID>\MSI\{ProductCode}. The IME sits under the device-scope
|
||||
// zeros-SID, but enumerate all SIDs — layout is the same for user scope.
|
||||
foreach (var sid in rootKey.GetSubKeyNames())
|
||||
{
|
||||
using (var msiKey = rootKey.OpenSubKey(sid + @"\MSI"))
|
||||
{
|
||||
if (msiKey == null) continue;
|
||||
foreach (var productCode in msiKey.GetSubKeyNames())
|
||||
{
|
||||
using (var productKey = msiKey.OpenSubKey(productCode))
|
||||
{
|
||||
if (productKey == null) continue;
|
||||
candidates.Add(new MsiEntry(
|
||||
productCode: productCode,
|
||||
productVersion: productKey.GetValue("ProductVersion") as string,
|
||||
downloadUrl: productKey.GetValue("CurrentDownloadUrl") as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SelectBestMatch(candidates, imeVersion, logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: read threw: {ex.Message}");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure selection logic (internal for unit tests): exact/normalized ProductVersion match
|
||||
/// wins; otherwise the first entry whose download URL points at
|
||||
/// <c>IntuneWindowsAgent.msi</c>.
|
||||
/// </summary>
|
||||
internal static ImeMsiInstallSource SelectBestMatch(
|
||||
IReadOnlyList<MsiEntry> candidates, string imeVersion, AgentLogger logger = null)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return ImeMsiInstallSource.Empty;
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (VersionsEqual(entry.ProductVersion, imeVersion))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by ProductVersion {entry.ProductVersion} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: true);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
if (IsImeMsiUrl(entry.DownloadUrl))
|
||||
{
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: matched by URL filename, registry version {entry.ProductVersion ?? "?"} vs log {imeVersion ?? "?"} ({entry.ProductCode})");
|
||||
return new ImeMsiInstallSource(entry, matchedByVersion: false);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Debug($"ImeMsiInstallSourceProbe: no CSP MSI entry matches IME version {imeVersion ?? "?"} ({candidates.Count} candidates)");
|
||||
return ImeMsiInstallSource.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component-wise numeric compare with trailing-zero tolerance, so
|
||||
/// <c>1.104.102</c> == <c>1.104.102.0</c>. Non-numeric components fall back to an
|
||||
/// ordinal-ignore-case string compare of the whole version.
|
||||
/// </summary>
|
||||
internal static bool VersionsEqual(string a, string b)
|
||||
{
|
||||
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
|
||||
if (string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
var pa = a.Trim().Split('.');
|
||||
var pb = b.Trim().Split('.');
|
||||
var len = Math.Max(pa.Length, pb.Length);
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
var sa = i < pa.Length ? pa[i] : "0";
|
||||
var sb = i < pb.Length ? pb[i] : "0";
|
||||
if (!int.TryParse(sa, out var na) || !int.TryParse(sb, out var nb)) return false;
|
||||
if (na != nb) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>URL filename equals <c>IntuneWindowsAgent.msi</c> (query string ignored).</summary>
|
||||
internal static bool IsImeMsiUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
var path = url;
|
||||
var query = path.IndexOf('?');
|
||||
if (query >= 0) path = path.Substring(0, query);
|
||||
var slash = path.LastIndexOf('/');
|
||||
var fileName = slash >= 0 ? path.Substring(slash + 1) : path;
|
||||
return string.Equals(fileName, ImeMsiFileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Raw candidate row from one <c>MSI\{ProductCode}</c> subkey.</summary>
|
||||
internal readonly struct MsiEntry
|
||||
{
|
||||
public MsiEntry(string productCode, string productVersion, string downloadUrl)
|
||||
{
|
||||
ProductCode = productCode;
|
||||
ProductVersion = productVersion;
|
||||
DownloadUrl = downloadUrl;
|
||||
}
|
||||
|
||||
public string ProductCode { get; }
|
||||
public string ProductVersion { get; }
|
||||
public string DownloadUrl { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposable scope that sets <see cref="TestOverride"/> for the lifetime of the scope
|
||||
/// and restores the previous value on Dispose. Nestable. Internal test-only helper.
|
||||
/// </summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<string, AgentLogger, ImeMsiInstallSource> _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<string, AgentLogger, ImeMsiInstallSource> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved install source of the running IME build. <see cref="HasData"/> is <c>false</c>
|
||||
/// when no CSP MSI entry matched. <see cref="MatchedByVersion"/> distinguishes a
|
||||
/// ProductVersion match (URL is authoritative for the reported build) from the
|
||||
/// filename fallback (URL is the currently enforced IME MSI, version may have drifted).
|
||||
/// </summary>
|
||||
internal readonly struct ImeMsiInstallSource
|
||||
{
|
||||
public static readonly ImeMsiInstallSource Empty = default;
|
||||
|
||||
internal ImeMsiInstallSource(ImeMsiInstallSourceProbe.MsiEntry entry, bool matchedByVersion)
|
||||
{
|
||||
HasData = true;
|
||||
ProductCode = entry.ProductCode;
|
||||
ProductVersion = entry.ProductVersion;
|
||||
DownloadUrl = entry.DownloadUrl;
|
||||
MatchedByVersion = matchedByVersion;
|
||||
}
|
||||
|
||||
public bool HasData { get; }
|
||||
|
||||
/// <summary>Braced MSI ProductCode subkey name, e.g. <c>{6a7dfc50-0395-4e1e-bf84-ed1404e72051}</c>.</summary>
|
||||
public string ProductCode { get; }
|
||||
|
||||
/// <summary><c>ProductVersion</c> registry value (may be null on partial writes).</summary>
|
||||
public string ProductVersion { get; }
|
||||
|
||||
/// <summary><c>CurrentDownloadUrl</c> registry value (may be null on partial writes).</summary>
|
||||
public string DownloadUrl { get; }
|
||||
|
||||
/// <summary><c>true</c> = matched via ProductVersion; <c>false</c> = URL-filename fallback.</summary>
|
||||
public bool MatchedByVersion { get; }
|
||||
}
|
||||
}
|
||||
+643
@@ -0,0 +1,643 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry "second pillar" for IME app state (audit 2026-08-17, architecture decision:
|
||||
/// registry = stable truth, IME logs = narrative). IME persists its authoritative per-app
|
||||
/// state under <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Win32Apps\<userGuid>\<appGuid>_<rev></c> — EnforcementStateMessage JSON
|
||||
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
|
||||
/// <item><c>EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev></c> — which apps IME
|
||||
/// registered for ESP tracking and in which phase.</item>
|
||||
/// <item><c>SideCarPolicies\StatusServiceReports\<userGuid>\<appId></c> — the exact
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
|
||||
/// </list>
|
||||
/// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only,
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the three
|
||||
/// surfaces and emits <c>registry_app_state</c> on real field changes. Pre-existing state at
|
||||
/// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying
|
||||
/// them as fresh events would be the registry twin of the historic-IME-replay bug.
|
||||
///
|
||||
/// Reconciliation (= built-in log-pattern drift alarm): once a registry entry that changed
|
||||
/// during THIS session has been terminal for <see cref="ReconcileSettleDelay"/>, its outcome
|
||||
/// is compared against the log-derived <see cref="AppPackageState"/>. A divergence means the
|
||||
/// IME log patterns missed or mis-classified the app — emitted once per app as
|
||||
/// <c>app_state_reconciliation</c> (Warning). Pure observability: nothing here feeds the
|
||||
/// DecisionEngine.
|
||||
/// </summary>
|
||||
internal sealed class ImeRegistryAppStateObserver
|
||||
{
|
||||
public const string SourceLabel = "RegistryAppState";
|
||||
|
||||
internal const string ImeRootKeyPath = @"SOFTWARE\Microsoft\IntuneManagementExtension";
|
||||
|
||||
// Safety cap: a pathological churn (or a wrong diff) must not flood the timeline.
|
||||
// 200 covers ~25 apps x 8 state transitions; the cap itself is announced once.
|
||||
internal const int MaxStateEventsPerSession = 200;
|
||||
|
||||
// The log tracker legitimately lags the registry (log write + 100 ms poll + phase
|
||||
// gating), so divergence is only evaluated after the registry state has been stable
|
||||
// terminal for this long.
|
||||
internal static readonly TimeSpan ReconcileSettleDelay = TimeSpan.FromSeconds(90);
|
||||
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly Func<IReadOnlyList<AppPackageState>>? _trackerStateProbe;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private ImeRegistrySnapshot? _last;
|
||||
private readonly Dictionary<string, DateTime> _terminalSinceUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _changedDuringSession =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reconciliationEmitted =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
private int _stateEventsEmitted;
|
||||
private bool _capNoticeEmitted;
|
||||
|
||||
// Test seam: replaces the live registry walk (same convention as EspTrackingInfoProbe).
|
||||
internal static Func<ImeRegistrySnapshot>? SnapshotOverride;
|
||||
|
||||
internal sealed class ScopedSnapshotOverride : IDisposable
|
||||
{
|
||||
private readonly Func<ImeRegistrySnapshot>? _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedSnapshotOverride(Func<ImeRegistrySnapshot> replacement)
|
||||
{
|
||||
_previous = SnapshotOverride;
|
||||
SnapshotOverride = replacement;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
SnapshotOverride = _previous;
|
||||
}
|
||||
}
|
||||
|
||||
public ImeRegistryAppStateObserver(
|
||||
InformationalEventPost post,
|
||||
AgentLogger? logger,
|
||||
IClock clock,
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
|
||||
{
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
_trackerStateProbe = trackerStateProbe;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One observation pass: snapshot → diff → emit → reconcile. Called from the debounced
|
||||
/// registry-change edge AND a periodic fallback tick (the settle-delay evaluation must
|
||||
/// happen even when the registry goes quiet). Fail-soft: never throws.
|
||||
/// </summary>
|
||||
public void Tick(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var snapshot = SnapshotOverride?.Invoke() ?? ReadSnapshot();
|
||||
var nowUtc = _clock.UtcNow;
|
||||
|
||||
if (_last == null)
|
||||
{
|
||||
// Silent baseline for the DIFF stream — see class doc. But the baseline
|
||||
// itself is valuable evidence: on WDP the DPP-policy apps install in
|
||||
// Batch 1 BEFORE the agent (bootstrap = Batch 2), so their terminal
|
||||
// states exist ONLY here. One capped summary event closes that
|
||||
// pre-agent gap without any log backfill (Oliver, 2026-08-18).
|
||||
_last = snapshot;
|
||||
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
|
||||
if (snapshot.Entries.Count > 0)
|
||||
EmitBaselineSummary(snapshot, nowUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
var changes = DiffSnapshots(_last, snapshot);
|
||||
foreach (var change in changes)
|
||||
{
|
||||
_changedDuringSession.Add(change.Entry.Key);
|
||||
EmitStateEvent(change, nowUtc);
|
||||
}
|
||||
|
||||
TrackTerminalTransitions(snapshot, nowUtc);
|
||||
EvaluateReconciliations(snapshot, nowUtc);
|
||||
|
||||
_last = snapshot;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Debug($"RegistryAppState: tick failed (fail-soft): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── snapshot ────────────────────────────────────────────────────────────
|
||||
|
||||
internal static ImeRegistrySnapshot ReadSnapshot()
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var root = baseKey.OpenSubKey(ImeRootKeyPath);
|
||||
if (root == null) return snapshot;
|
||||
|
||||
ReadWin32Apps(root, snapshot);
|
||||
ReadEspTracking(root, snapshot);
|
||||
ReadStatusServiceReports(root, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static void ReadWin32Apps(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var win32Apps = root.OpenSubKey("Win32Apps");
|
||||
if (win32Apps == null) return;
|
||||
|
||||
foreach (var userName in win32Apps.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue; // skips OperationalState / Reporting / ProvisioningProgress
|
||||
using var userKey = win32Apps.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue; // skips GRS + non-app keys
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode"));
|
||||
entry.Intent = TryReadInt(appKey.GetValue("Intent"));
|
||||
entry.LastUpdatedUtc = appKey.GetValue("LastUpdatedTimeUtc") as string;
|
||||
|
||||
using var esm = appKey.OpenSubKey("EnforcementStateMessage");
|
||||
var json = esm?.GetValue("EnforcementStateMessage") as string;
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
var (state, error) = ParseEnforcementStateMessage(json!);
|
||||
entry.EnforcementState = state;
|
||||
entry.ErrorCode = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadEspTracking(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var esp = root.OpenSubKey("EspTrackingWin32Apps");
|
||||
if (esp == null) return;
|
||||
|
||||
foreach (var userName in esp.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = esp.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.EspTracked = true;
|
||||
entry.EspPhase = appKey?.GetValue("EspTrackingWin32AppPhase") as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadStatusServiceReports(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var reports = root.OpenSubKey(@"SideCarPolicies\StatusServiceReports");
|
||||
if (reports == null) return;
|
||||
|
||||
foreach (var userName in reports.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = reports.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.StatusServiceStatus = TryReadInt(appKey.GetValue("Status"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested directly) ─────────────────────────────────
|
||||
|
||||
internal static bool LooksLikeGuid(string name) =>
|
||||
!string.IsNullOrEmpty(name) && Guid.TryParse(name, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Win32Apps app keys are <c><appGuid>_<revision></c>; StatusServiceReports keys
|
||||
/// are the bare GUID. Returns the lowercase app GUID or null for non-app keys (GRS, ...).
|
||||
/// </summary>
|
||||
internal static string? ExtractAppId(string keyName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyName)) return null;
|
||||
var candidate = keyName.Length >= 36 ? keyName.Substring(0, 36) : keyName;
|
||||
return Guid.TryParse(candidate, out var g) ? g.ToString("D") : null;
|
||||
}
|
||||
|
||||
internal static (int? state, long? errorCode) ParseEnforcementStateMessage(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
int? state = null;
|
||||
long? error = null;
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("EnforcementState", out var s) && s.ValueKind == JsonValueKind.Number)
|
||||
state = s.GetInt32();
|
||||
if (doc.RootElement.TryGetProperty("ErrorCode", out var e) && e.ValueKind == JsonValueKind.Number)
|
||||
error = e.GetInt64();
|
||||
}
|
||||
return (state, error);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static int? TryReadInt(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: return null;
|
||||
case int i: return i;
|
||||
case long l: return unchecked((int)l);
|
||||
case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed):
|
||||
return parsed;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
if (state >= 1000 && state < 2000) return "success";
|
||||
if (state >= 2000 && state < 3000) return "inProgress";
|
||||
if (state >= 3000 && state < 4000) return "requirementsNotMet";
|
||||
if (state >= 5000 && state < 6000) return "error";
|
||||
if (state >= 6000 && state < 7000) return "notAttempted";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
|
||||
internal static string? ClassifyStatusServiceStatus(int status)
|
||||
{
|
||||
if (status >= 1000 && status < 2000) return "installed";
|
||||
if (status == 3000) return "failed";
|
||||
if (status >= 2000 && status < 3000) return "notApplicable";
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
|
||||
{
|
||||
var changes = new List<AppRegistryChange>();
|
||||
foreach (var kv in next.Entries)
|
||||
{
|
||||
previous.Entries.TryGetValue(kv.Key, out var prev);
|
||||
var changed = ChangedFields(prev, kv.Value);
|
||||
if (changed.Count > 0)
|
||||
changes.Add(new AppRegistryChange(kv.Value, changed, isNew: prev == null));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
internal static List<string> ChangedFields(AppRegistryEntry? prev, AppRegistryEntry next)
|
||||
{
|
||||
var changed = new List<string>();
|
||||
if (prev?.EnforcementState != next.EnforcementState) changed.Add("enforcementState");
|
||||
if (prev?.ErrorCode != next.ErrorCode) changed.Add("errorCode");
|
||||
if (prev?.ExitCode != next.ExitCode) changed.Add("exitCode");
|
||||
if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus");
|
||||
if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked");
|
||||
if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase");
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>"success" / "error" when the registry outcome is terminal, else null.</summary>
|
||||
internal static string? TerminalOutcome(AppRegistryEntry entry)
|
||||
{
|
||||
if (entry.EnforcementState is int es)
|
||||
{
|
||||
var cls = ClassifyEnforcementState(es);
|
||||
if (cls == "success") return "success";
|
||||
if (cls == "error") return "error";
|
||||
}
|
||||
if (entry.StatusServiceStatus is int ss)
|
||||
{
|
||||
var cls = ClassifyStatusServiceStatus(ss);
|
||||
if (cls == "installed") return "success";
|
||||
if (cls == "failed") return "error";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Divergence rules (observability, conservative — prefer false-negative over noise):
|
||||
/// registry error vs log Installed, registry success vs log Error, or registry terminal
|
||||
/// while the tracker (actively tracking other apps) never saw the app at all.
|
||||
/// </summary>
|
||||
internal static bool IsDivergent(
|
||||
string registryOutcome,
|
||||
AppPackageState? logState,
|
||||
bool trackerHasAnyApps,
|
||||
out string reasonCode)
|
||||
{
|
||||
if (logState == null)
|
||||
{
|
||||
if (trackerHasAnyApps)
|
||||
{
|
||||
reasonCode = "app_unknown_to_log_tracking";
|
||||
return true;
|
||||
}
|
||||
reasonCode = string.Empty; // tracker idle (e.g. pre-ESP) — not judgeable
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registryOutcome == "error" && logState.InstallationState == AppInstallationState.Installed)
|
||||
{
|
||||
reasonCode = "registry_error_log_installed";
|
||||
return true;
|
||||
}
|
||||
if (registryOutcome == "success" && logState.InstallationState == AppInstallationState.Error)
|
||||
{
|
||||
reasonCode = "registry_success_log_error";
|
||||
return true;
|
||||
}
|
||||
|
||||
reasonCode = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── emission ────────────────────────────────────────────────────────────
|
||||
|
||||
private void EmitStateEvent(AppRegistryChange change, DateTime nowUtc)
|
||||
{
|
||||
if (_stateEventsEmitted >= MaxStateEventsPerSession)
|
||||
{
|
||||
if (!_capNoticeEmitted)
|
||||
{
|
||||
_capNoticeEmitted = true;
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"registry_app_state cap reached ({MaxStateEventsPerSession}) — further registry app-state changes are logged locally only",
|
||||
severity: EventSeverity.Warning,
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
_logger?.Debug($"RegistryAppState: cap reached, suppressing {change.Entry.AppId}");
|
||||
return;
|
||||
}
|
||||
_stateEventsEmitted++;
|
||||
|
||||
var e = change.Entry;
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = e.AppId,
|
||||
["userContext"] = e.UserContext,
|
||||
["changedFields"] = string.Join(",", change.ChangedFields),
|
||||
};
|
||||
if (e.EnforcementState is int es)
|
||||
{
|
||||
data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
|
||||
data["enforcementClass"] = ClassifyEnforcementState(es);
|
||||
}
|
||||
if (e.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.ExitCode is int xc) data["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.Intent is int intent) data["intent"] = intent.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.StatusServiceStatus is int ss)
|
||||
{
|
||||
data["statusServiceStatus"] = ss.ToString(CultureInfo.InvariantCulture);
|
||||
var cls = ClassifyStatusServiceStatus(ss);
|
||||
if (cls != null) data["statusServiceClass"] = cls;
|
||||
}
|
||||
if (e.EspTracked) data["espTracked"] = "true";
|
||||
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
|
||||
|
||||
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
|
||||
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
|
||||
: "updated";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
|
||||
// Baseline entries in the summary's typed payload are capped — a long-lived device
|
||||
// with many historical Win32Apps entries must not produce an oversized event.
|
||||
internal const int MaxBaselineSummaryApps = 50;
|
||||
|
||||
private void EmitBaselineSummary(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
int success = 0, error = 0, inProgress = 0, other = 0;
|
||||
var apps = new List<Dictionary<string, string>>();
|
||||
foreach (var entry in snapshot.Entries.Values)
|
||||
{
|
||||
var cls = entry.EnforcementState is int es ? ClassifyEnforcementState(es) : null;
|
||||
switch (cls)
|
||||
{
|
||||
case "success": success++; break;
|
||||
case "error": error++; break;
|
||||
case "inProgress": inProgress++; break;
|
||||
default: other++; break;
|
||||
}
|
||||
|
||||
if (apps.Count >= MaxBaselineSummaryApps) continue;
|
||||
var app = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = entry.AppId,
|
||||
["userContext"] = entry.UserContext,
|
||||
};
|
||||
if (entry.EnforcementState is int state)
|
||||
{
|
||||
app["enforcementState"] = state.ToString(CultureInfo.InvariantCulture);
|
||||
app["enforcementClass"] = ClassifyEnforcementState(state);
|
||||
}
|
||||
if (entry.ErrorCode is long ec) app["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
if (entry.ExitCode is int xc) app["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(entry.LastUpdatedUtc)) app["lastUpdatedUtc"] = entry.LastUpdatedUtc!;
|
||||
if (entry.EspTracked) app["espTracked"] = "true";
|
||||
apps.Add(app);
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["totalApps"] = snapshot.Entries.Count.ToString(CultureInfo.InvariantCulture),
|
||||
["successCount"] = success.ToString(CultureInfo.InvariantCulture),
|
||||
["errorCount"] = error.ToString(CultureInfo.InvariantCulture),
|
||||
["inProgressCount"] = inProgress.ToString(CultureInfo.InvariantCulture),
|
||||
["otherCount"] = other.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
if (snapshot.Entries.Count > MaxBaselineSummaryApps)
|
||||
data["appListTruncated"] = "true";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppBaseline,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app baseline at agent start: {snapshot.Entries.Count} apps ({success} success, {error} error, {inProgress} in progress) — state that predates this agent run",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc,
|
||||
typedPayload: new { apps });
|
||||
}
|
||||
|
||||
private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
foreach (var kv in snapshot.Entries)
|
||||
{
|
||||
if (!_changedDuringSession.Contains(kv.Key)) continue; // baseline state — not ours to judge
|
||||
var outcome = TerminalOutcome(kv.Value);
|
||||
if (outcome == null)
|
||||
{
|
||||
_terminalSinceUtc.Remove(kv.Key); // regressed to non-terminal — reset dwell
|
||||
continue;
|
||||
}
|
||||
if (!_terminalSinceUtc.ContainsKey(kv.Key))
|
||||
_terminalSinceUtc[kv.Key] = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
private void EvaluateReconciliations(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
if (_trackerStateProbe == null) return;
|
||||
|
||||
List<AppPackageState>? trackerStates = null;
|
||||
foreach (var kv in _terminalSinceUtc.ToList())
|
||||
{
|
||||
if (_reconciliationEmitted.Contains(kv.Key)) continue;
|
||||
if (nowUtc - kv.Value < ReconcileSettleDelay) continue;
|
||||
if (!snapshot.Entries.TryGetValue(kv.Key, out var entry)) continue;
|
||||
|
||||
var outcome = TerminalOutcome(entry);
|
||||
if (outcome == null) continue;
|
||||
|
||||
trackerStates ??= (_trackerStateProbe() ?? Array.Empty<AppPackageState>()).ToList();
|
||||
var logState = trackerStates.FirstOrDefault(p =>
|
||||
string.Equals(p.Id, entry.AppId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!IsDivergent(outcome, logState, trackerStates.Count > 0, out var reasonCode))
|
||||
{
|
||||
_reconciliationEmitted.Add(kv.Key); // agreed (or not judgeable) — done with this app
|
||||
continue;
|
||||
}
|
||||
|
||||
_reconciliationEmitted.Add(kv.Key);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = entry.AppId,
|
||||
["userContext"] = entry.UserContext,
|
||||
["registryOutcome"] = outcome,
|
||||
["reason"] = reasonCode,
|
||||
["logState"] = logState?.InstallationState.ToString() ?? "unknown",
|
||||
};
|
||||
if (logState != null && !string.IsNullOrEmpty(logState.Name)) data["appName"] = logState.Name;
|
||||
if (entry.EnforcementState is int es) data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture);
|
||||
if (entry.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.AppStateReconciliation,
|
||||
source: SourceLabel,
|
||||
message: $"Registry/log divergence for app {entry.AppId}: registry={outcome}, log={data["logState"]} ({reasonCode}) — IME log patterns may have drifted",
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
|
||||
_logger?.Warning($"RegistryAppState: divergence for {entry.AppId} — registry={outcome}, log={data["logState"]} ({reasonCode})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Immutable-ish snapshot of the three IME registry surfaces, keyed per user-context + app.</summary>
|
||||
internal sealed class ImeRegistrySnapshot
|
||||
{
|
||||
public const string DeviceContext = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
public Dictionary<string, AppRegistryEntry> Entries { get; } =
|
||||
new Dictionary<string, AppRegistryEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AppRegistryEntry GetOrAdd(string userContext, string appId)
|
||||
{
|
||||
var normalizedUser = Guid.TryParse(userContext, out var g) ? g.ToString("D") : userContext;
|
||||
var key = normalizedUser + "|" + appId;
|
||||
if (!Entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry = new AppRegistryEntry(key, normalizedUser, appId);
|
||||
Entries[key] = entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryEntry
|
||||
{
|
||||
public AppRegistryEntry(string key, string userContext, string appId)
|
||||
{
|
||||
Key = key;
|
||||
UserContext = userContext;
|
||||
AppId = appId;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
public string UserContext { get; }
|
||||
public string AppId { get; }
|
||||
public string? LastUpdatedUtc { get; set; }
|
||||
public int? EnforcementState { get; set; }
|
||||
public long? ErrorCode { get; set; }
|
||||
public int? ExitCode { get; set; }
|
||||
public int? Intent { get; set; }
|
||||
public int? StatusServiceStatus { get; set; }
|
||||
public bool EspTracked { get; set; }
|
||||
public string? EspPhase { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryChange
|
||||
{
|
||||
public AppRegistryChange(AppRegistryEntry entry, List<string> changedFields, bool isNew)
|
||||
{
|
||||
Entry = entry;
|
||||
ChangedFields = changedFields;
|
||||
IsNew = isNew;
|
||||
}
|
||||
|
||||
public AppRegistryEntry Entry { get; }
|
||||
public List<string> ChangedFields { get; }
|
||||
public bool IsNew { get; }
|
||||
}
|
||||
}
|
||||
+38
-2
@@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json";
|
||||
|
||||
/// <summary>
|
||||
/// EventIDs that are STRUCTURAL noise on a Device Preparation session and are
|
||||
/// suppressed there entirely (afee7ae0: 900+ occurrences of 1005 plus 400+ of
|
||||
/// 417/418 in a nine-minute session, all expected): 100 "Autopilot policy not
|
||||
/// found" (no deployment profile exists on WDP by design), 417/418 "duplicate
|
||||
/// deployment workload/batch" (BootstrapperAgent SLDM progress-poll chatter),
|
||||
/// 1005 ManagementService WIL error storm. Genuine WDP diagnostics (e.g. 408
|
||||
/// provisioning warnings) keep flowing; Critical (level 1) records are never
|
||||
/// suppressed. Classic sessions are unaffected — they keep the configurable
|
||||
/// harmless-downgrade + rollup path instead.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<int> DevicePreparationNoiseEventIds = new HashSet<int> { 100, 417, 418, 1005 };
|
||||
|
||||
/// <summary>
|
||||
/// Per-EventId emission caps for harmless-downgraded events (session 8bc1180f,
|
||||
/// 2026-06-12). Windows can dump hundreds of identical EventID-100 "Autopilot policy
|
||||
@@ -93,6 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
@@ -131,7 +145,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] harmlessEventIds = null)
|
||||
int[] harmlessEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -140,6 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_logLevelMax = logLevelMax;
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
|
||||
? new HashSet<int>(harmlessEventIds)
|
||||
@@ -166,7 +182,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_backfillEnabled)
|
||||
{
|
||||
BackfillTargetedEvents();
|
||||
BackfillAutopilotErrors();
|
||||
// WDP gate (afee7ae0): the error backfill replays ZTD registration /
|
||||
// attestation errors (807/809/815/908) — on Device Preparation the 807
|
||||
// ZtdDeviceIsNotRegistered answer is the EXPECTED state, not a failure.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
BackfillAutopilotErrors();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("ModernDeployment: Device Preparation session — Autopilot error backfill skipped (ZTD errors are structural there)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -393,6 +419,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
var effectiveLevel = level ?? 4;
|
||||
|
||||
// WDP structural-noise gate — see DevicePreparationNoiseEventIds. Sits before
|
||||
// the severity mapping so suppressed records also skip FormatDescription().
|
||||
// Critical (1) and LogAlways (0) records always pass.
|
||||
if (_isDevicePreparation
|
||||
&& effectiveLevel >= 2
|
||||
&& DevicePreparationNoiseEventIds.Contains(eventId))
|
||||
{
|
||||
return EventVerdict.Suppress;
|
||||
}
|
||||
switch (effectiveLevel)
|
||||
{
|
||||
case 1:
|
||||
|
||||
+13
-8
@@ -107,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _deviceSetupProvisioningCompleteFired;
|
||||
// Fire-once guard for AccountSetupProvisioningComplete event (session 330f73f3 fix)
|
||||
private bool _accountSetupProvisioningCompleteFired;
|
||||
// WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded
|
||||
// DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded. NOT a WhiteGlove
|
||||
// indicator on Win11 25H2+ (see HasSaveWhiteGloveSuccessResult) — observability only.
|
||||
private bool _saveWhiteGloveSuccessResultSeen;
|
||||
// Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded)
|
||||
private string _lastSaveWhiteGloveState;
|
||||
@@ -226,8 +227,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property
|
||||
/// with subcategoryState=succeeded. This is a definitive WhiteGlove (Pre-Provisioning)
|
||||
/// confirmation signal — Windows only writes this property during White Glove flows.
|
||||
/// with subcategoryState=succeeded. NOT a WhiteGlove indicator: on Win11 25H2 (build
|
||||
/// 26200+) Windows writes this property as a trivially-succeeding step in EVERY
|
||||
/// enrollment (validated 2026-08-18 — user-driven sessions f475e697/c601a24b/4219203f
|
||||
/// all carry it). Kept for observability only (whiteglove_signal_* raw dumps); must not
|
||||
/// be used for classification. Reliable WhiteGlove anchors are ModernDeployment
|
||||
/// EventID 509 and the Shell-Core WhiteGlove-success events.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult
|
||||
{
|
||||
@@ -891,10 +896,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage");
|
||||
var subcategories = ParseSubcategories(root);
|
||||
|
||||
// Detect WhiteGlove signal in DeviceSetup category.
|
||||
// SaveWhiteGloveSuccessResult is NOT a *Subcategory-suffixed property, so
|
||||
// ParseSubcategories skips it. We scan the raw JSON explicitly.
|
||||
// Track state transitions for full observability (notStarted → succeeded).
|
||||
// Track the SaveWhiteGloveSuccessResult transitions in the DeviceSetup
|
||||
// category (observability only — NOT a WhiteGlove indicator on 25H2+, see
|
||||
// HasSaveWhiteGloveSuccessResult). It is NOT a *Subcategory-suffixed
|
||||
// property, so ParseSubcategories skips it; we scan the raw JSON explicitly.
|
||||
if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
@@ -919,7 +924,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_saveWhiteGloveSuccessResultSeen = true;
|
||||
_logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " +
|
||||
"— WhiteGlove confirmation signal");
|
||||
"(observability only — not a WhiteGlove indicator on 25H2+)");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
+20
-2
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly HashSet<string> _sources;
|
||||
private readonly int _sessionStalledAfterProbeIndex;
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
@@ -151,7 +152,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int[] traceIndices,
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null)
|
||||
int[] harmlessModernDeploymentEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -161,10 +163,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
|
||||
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_harmlessModernDeploymentEventIds = new HashSet<int>(
|
||||
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
|
||||
? harmlessModernDeploymentEventIds
|
||||
: DefaultHarmlessModernDeploymentEventIds);
|
||||
|
||||
// WDP gate (afee7ae0): the structural WDP noise IDs (417/418 duplicate-workload
|
||||
// chatter, 1005 WIL storm, ...) must never count as stall anomalies on Device
|
||||
// Preparation, independent of what the tenant-configured harmless list contains.
|
||||
// The channels themselves stay scanned — WDP's provisioning stack logs genuine
|
||||
// diagnostics (e.g. 408) into the very same Autopilot channel.
|
||||
if (_isDevicePreparation)
|
||||
{
|
||||
_harmlessModernDeploymentEventIds.UnionWith(ModernDeploymentTracker.DevicePreparationNoiseEventIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -244,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds;
|
||||
|
||||
// Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability.
|
||||
// Always log locally for diagnostics — agent.log is the always-on observability.
|
||||
var anomaliesStr = result.Anomalies.Count > 0
|
||||
? $"anomalies=[{string.Join(",", result.Anomalies)}]"
|
||||
: "quiet";
|
||||
@@ -368,6 +381,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private void ScanProvisioningRegistry(ProbeResult result)
|
||||
{
|
||||
// Device Preparation writes no ESP category values — the three Status JSONs
|
||||
// below are ESP-only constructs, so the scan can never find anything there.
|
||||
if (_isDevicePreparation)
|
||||
return;
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
|
||||
+199
-29
@@ -346,11 +346,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
// Chicken-and-egg guard: the agent-log snapshot is zipped BEFORE packaging finishes, so
|
||||
// packaging problems (blocked paths, missing files, failed copies) are invisible in the
|
||||
// uploaded archive's own log. This manifest records every packaging decision and travels
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
}
|
||||
|
||||
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
|
||||
// tests can assert archive contents without going through the upload path.
|
||||
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
|
||||
{
|
||||
var tracker = new BudgetTracker(Budget);
|
||||
_manifest = new StringBuilder();
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -394,12 +406,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -407,9 +421,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (string.IsNullOrEmpty(folder)) continue;
|
||||
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
|
||||
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
|
||||
ManifestLine($"CONFIGURED PATH: '{entry.Path}' -> folder='{folder}' pattern='{pattern}'");
|
||||
AddLogFiles(archive, folder, zipFolder, pattern, tracker, entry.IncludeSubfolders);
|
||||
}
|
||||
|
||||
// Packaging manifest — always written, even when empty of problems, so its
|
||||
// absence itself is meaningful (archive predates the feature).
|
||||
try
|
||||
{
|
||||
var manifestEntry = archive.CreateEntry("package-manifest.txt", CompressionLevel.Optimal);
|
||||
using (var writer = new StreamWriter(manifestEntry.Open()))
|
||||
writer.Write(_manifest.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to write package manifest: {ex.Message}");
|
||||
}
|
||||
|
||||
// Always last: emit truncation report only if any file was skipped.
|
||||
if (tracker.HasSkips)
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
@@ -483,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!Directory.Exists(sourceFolder))
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -494,9 +523,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0)
|
||||
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
@@ -514,6 +547,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Skipping reparse-point file: {file}");
|
||||
tracker.RecordSkip(file, "reparse", 0);
|
||||
ManifestLine($"SKIPPED (reparse point): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -525,48 +559,184 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
continue;
|
||||
}
|
||||
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
// Active event-log channels hold their .evtx exclusively locked — a raw
|
||||
// FileStream open fails with a sharing violation. Export the channel via
|
||||
// wevtutil instead and stream the export (deleted below). Session a11102f4:
|
||||
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
|
||||
string streamSource = file;
|
||||
string tempExport = null;
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
continue;
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport != null)
|
||||
{
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Export failed (channel not resolvable / wevtutil error) — fall through
|
||||
// to the raw copy. Inactive or archived evtx files are not locked, so
|
||||
// the plain FileStream often still succeeds; if the channel IS active
|
||||
// and locked, the existing per-file catch logs the failure.
|
||||
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
|
||||
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
|
||||
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
|
||||
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var entryStream = entry.Open())
|
||||
try
|
||||
{
|
||||
fs.CopyTo(entryStream);
|
||||
}
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
ManifestLine($"SKIPPED (file-count cap): {file}");
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
ManifestLine($"SKIPPED (total-bytes cap): {file}");
|
||||
continue;
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
|
||||
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
|
||||
using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
fs.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
ManifestLine($"ADDED: {entryName} ({length} bytes)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempExport != null)
|
||||
{
|
||||
try { File.Delete(tempExport); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the event-log CHANNEL that writes to the given .evtx file by matching the
|
||||
/// registered channels' LogFilePath — never by guessing a naming convention: most
|
||||
/// channels follow the "%4 encodes '/'" file naming, but not all do (field case
|
||||
/// 2026-08-17: Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
|
||||
/// writes plain "BootstrapperAgentServiceLogProvider.evtx"). Falls back to the %4
|
||||
/// convention only when no registered channel claims the file.
|
||||
/// </summary>
|
||||
internal static string ResolveChannelForEvtxFile(string evtxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var target = Path.GetFullPath(evtxPath);
|
||||
using (var session = new System.Diagnostics.Eventing.Reader.EventLogSession())
|
||||
{
|
||||
foreach (var logName in session.GetLogNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = new System.Diagnostics.Eventing.Reader.EventLogConfiguration(logName, session);
|
||||
var logFile = config.LogFilePath;
|
||||
if (string.IsNullOrEmpty(logFile)) continue;
|
||||
var expanded = Path.GetFullPath(Environment.ExpandEnvironmentVariables(logFile));
|
||||
if (string.Equals(expanded, target, StringComparison.OrdinalIgnoreCase))
|
||||
return logName;
|
||||
}
|
||||
catch { /* individual channel unreadable — keep scanning */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* enumeration unavailable — fall back to convention */ }
|
||||
|
||||
return Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
|
||||
/// <c>wevtutil epl</c> (active channels hold their file exclusively locked, so a raw
|
||||
/// copy fails). Channel resolution happens against the registered channels — see
|
||||
/// <see cref="ResolveChannelForEvtxFile"/>. Returns the temp file path (caller deletes)
|
||||
/// or null when the export failed.
|
||||
/// </summary>
|
||||
private string TryExportEventLogChannel(string evtxPath)
|
||||
{
|
||||
string tempPath = null;
|
||||
try
|
||||
{
|
||||
var channel = ResolveChannelForEvtxFile(evtxPath);
|
||||
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(Environment.SystemDirectory, "wevtutil.exe"),
|
||||
Arguments = $"epl \"{channel}\" \"{tempPath}\" /ow:true",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using (var process = System.Diagnostics.Process.Start(psi))
|
||||
{
|
||||
if (process == null) return null;
|
||||
if (!process.WaitForExit(30000))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Warning($"wevtutil export timed out for channel '{channel}'.");
|
||||
ManifestLine($"EVTX EXPORT TIMEOUT: channel '{channel}' ({evtxPath})");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
_logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}");
|
||||
ManifestLine($"EVTX EXPORT ERROR: channel '{channel}' exit {process.ExitCode}: {stderr}");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
ManifestLine($"EVTX EXPORTED: channel '{channel}' -> {evtxPath}");
|
||||
return tempPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Event log export failed for {evtxPath}: {ex.Message}");
|
||||
if (tempPath != null)
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteTruncatedMarker(ZipArchive archive, BudgetTracker tracker)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -972,7 +972,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Appends a log line to the main agent log file (logger isn't initialized yet at update time).
|
||||
/// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log
|
||||
/// Uses the same naming and format as AgentLogger: agent.log
|
||||
/// </summary>
|
||||
private static void LogToFile(string message)
|
||||
{
|
||||
@@ -980,8 +980,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
|
||||
var logPath = Path.Combine(logDir, logFileName);
|
||||
var logPath = Path.Combine(logDir, "agent.log");
|
||||
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}");
|
||||
}
|
||||
catch
|
||||
|
||||
+55
-32
@@ -156,7 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
|
||||
{
|
||||
var detectedType = "v1";
|
||||
// Single source of truth for the rail classification — the SAME detector the
|
||||
// session registration uses (incl. the WDP DevicePreparation subkey probe). The
|
||||
// previous inline CloudAssigned*-only re-implementation kept reporting "v1" on
|
||||
// Device Preparation devices while the session row already said "v2" (a11102f4).
|
||||
var (detectedType, detectionRule) = AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
|
||||
var isHybridJoin = false;
|
||||
int? detectedAutopilotMode = null;
|
||||
|
||||
@@ -188,20 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key values for enrollment type detection
|
||||
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
|
||||
? data["CloudAssignedDeviceRegistration"]?.ToString()
|
||||
: null;
|
||||
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
|
||||
? data["CloudAssignedEspEnabled"]?.ToString()
|
||||
: null;
|
||||
|
||||
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
|
||||
if (deviceReg == "2" || espEnabled == "0")
|
||||
detectedType = "v2";
|
||||
else
|
||||
detectedType = "v1";
|
||||
|
||||
// --- Interpret AutopilotMode (profile capability, not live state) ---
|
||||
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
|
||||
var autopilotModeStr = data.ContainsKey("AutopilotMode")
|
||||
@@ -285,14 +275,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Include enrollment type in autopilot_profile event data
|
||||
data["enrollmentType"] = detectedType;
|
||||
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
var diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
// Device Preparation (WDP) has no Autopilot deployment profile by design:
|
||||
// Windows still runs the ZTD policy download during OOBE and caches the 807
|
||||
// ZtdDeviceIsNotRegistered answer, so without this gate every WDP session
|
||||
// emits an autopilot_profile dump plus a misleading "no deployment profile
|
||||
// was assigned" warning and burns a ZTD event-log query + HTTP probe on
|
||||
// evidence that answers a question nobody asked (afee7ae0). Gated on the
|
||||
// deterministic WDP rule only — the CloudAssigned* fallback rules imply a
|
||||
// real profile (just ESP-less), where the dump stays meaningful. The
|
||||
// enrollment_type_detected event below still carries the rail decision.
|
||||
var isDevicePreparation = detectionRule ==
|
||||
AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
Dictionary<string, object> diagnosticsRegistry = null;
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Device Preparation session — skipping autopilot_profile/ZTD evidence (no deployment profile exists on WDP)");
|
||||
}
|
||||
|
||||
// ProfileAvailable=0 means the ZTD service returned no profile for this device
|
||||
// during OOBE. Sessions only exist after the backend's device-registration
|
||||
@@ -303,7 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Autopilot session — surface the edge case explicitly, with a ZTD verdict
|
||||
// backed by the ModernDeployment Autopilot event log plus an endpoint probe
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
if (!isDevicePreparation && IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
@@ -377,11 +387,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{ "enrollmentType", detectedType },
|
||||
{ "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" },
|
||||
{ "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" },
|
||||
{ "detectionRule", detectedType == "v2"
|
||||
? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2"
|
||||
? "CloudAssignedDeviceRegistration=2"
|
||||
: "CloudAssignedEspEnabled=0")
|
||||
: "default (no v2 indicators)" }
|
||||
{ "detectionRule", detectionRule }
|
||||
});
|
||||
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
|
||||
@@ -801,10 +807,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
data["espTrackedModernCount"] = tracking.ModernCount;
|
||||
}
|
||||
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
// No FirstSync value and no ESP tracking list means there is no ESP evidence at
|
||||
// all (Device Preparation, plain Entra join, pre-sync timing) — an event that
|
||||
// says nothing but "unknown/unknown" is noise, so skip it. Mirrors the null-gate
|
||||
// the orchestrator bootstrap already applies to the decision signal
|
||||
// (EnrollmentOrchestrator.PostEspConfigDetectedBootstrap).
|
||||
var hasAnyEspEvidence = snapshot.SkipUser.HasValue
|
||||
|| snapshot.SkipDevice.HasValue
|
||||
|| snapshot.BlockInStatusPage.HasValue
|
||||
|| snapshot.SyncFailureTimeoutMinutes.HasValue
|
||||
|| tracking.HasData;
|
||||
if (hasAnyEspEvidence)
|
||||
{
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: no ESP configuration evidence (FirstSync empty, no tracking lists) — esp_config_detected suppressed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+8
-3
@@ -300,7 +300,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallCompleted, snap, EventSeverity.Info, "Completed", isTerminal: true, coreBinariesPresent: true);
|
||||
PersistState(OfficeInstallStateData.StateCompleted);
|
||||
PersistState(OfficeInstallStateData.StateCompleted, snap);
|
||||
return CompletionOutcome.Completed;
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
if (_state == DetectorState.Terminal) return;
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallFailed, snap, EventSeverity.Error, "Failed", isTerminal: true);
|
||||
PersistState(OfficeInstallStateData.StateFailed);
|
||||
PersistState(OfficeInstallStateData.StateFailed, snap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -444,15 +444,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// (the resume is what delivers a completion missed across a mid-install reboot).
|
||||
/// Caller holds the lock.
|
||||
/// </summary>
|
||||
private void PersistState(string state)
|
||||
private void PersistState(string state, OfficeC2RSnapshot? snap = null)
|
||||
{
|
||||
if (_statePersistence == null) return;
|
||||
_lastStatePersistUtc = _clock.UtcNow;
|
||||
var isTerminal = state == OfficeInstallStateData.StateCompleted
|
||||
|| state == OfficeInstallStateData.StateFailed;
|
||||
_statePersistence.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = state,
|
||||
StartedAtUtc = _startedAtUtc,
|
||||
StartedTrigger = _startedTrigger,
|
||||
// Terminal-only metadata for the summary dialog's Office row (duration + version).
|
||||
CompletedAtUtc = isTerminal ? _clock.UtcNow : (DateTime?)null,
|
||||
VersionReached = isTerminal ? snap?.VersionToReport : null,
|
||||
PeakDo = OfficeDoPeakData.FromSample(_peakDo),
|
||||
});
|
||||
}
|
||||
|
||||
+9
@@ -105,6 +105,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
public DateTime? StartedAtUtc { get; set; }
|
||||
public string? StartedTrigger { get; set; }
|
||||
|
||||
/// <summary>Stamped only on the two terminal persists (Completed/Failed) — basis for the
|
||||
/// summary dialog's Office row duration. Null on Active/Preinstalled and on state files
|
||||
/// written by older agents (fail-soft deserialization).</summary>
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Office build reached at the terminal transition (snapshot's VersionToReport).
|
||||
/// Null when the snapshot had none or on older state files.</summary>
|
||||
public string? VersionReached { get; set; }
|
||||
|
||||
/// <summary>Highest-bytes DO sample seen — basis for the doSummary on a resumed completion.</summary>
|
||||
public OfficeDoPeakData? PeakDo { get; set; }
|
||||
|
||||
|
||||
+25
-19
@@ -22,11 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Pressure thresholds — once either is crossed within a session, a one-shot
|
||||
// `spool_pressure_detected` event is emitted with RequiresImmediateFlush=true
|
||||
// so the backend can surface the condition without waiting for the next snapshot
|
||||
// to be queried out of Storage. Sized so a healthy 6h provisioning session never
|
||||
// trips them; tripping is a strong "upload backlog is growing or downstream stalled"
|
||||
// signal worth investigating in the field.
|
||||
// to be queried out of Storage. Both measure the PENDING backlog (items not yet
|
||||
// marked uploaded), never the append-only spool file size — a healthy long session
|
||||
// grows the file past any fixed size while its backlog stays near zero (field data
|
||||
// 2026-08: 99/100 pressure events were file-size-only false positives). Tripping is
|
||||
// therefore a genuine "upload is stalled or falling behind" signal.
|
||||
internal const int PressurePendingItemThreshold = 2000;
|
||||
internal const long PressureFileSizeBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
internal const long PressurePendingBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _agentVersion;
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
@@ -77,11 +79,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
// Full path writes 20 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=20 →
|
||||
// HashHelpers.GetPrime(20)=23 buckets → no resize on the 20th key.
|
||||
var data = new Dictionary<string, object>(capacity: 20, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -126,17 +128,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// EnrollmentOrchestrator.Start into IComponentFactory.CreateCollectorHosts
|
||||
// (ARCH-F4). Null only on test fakes that don't construct a real spool.
|
||||
int pendingItemCount = 0;
|
||||
long pendingBytes = 0L;
|
||||
long spoolFileSizeBytes = 0L;
|
||||
if (_telemetrySpool != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
pendingItemCount = _telemetrySpool.PendingItemCount;
|
||||
pendingBytes = _telemetrySpool.PendingBytes;
|
||||
spoolFileSizeBytes = _telemetrySpool.SpoolFileSizeBytes;
|
||||
var peakPending = _telemetrySpool.PeakPendingItemCount;
|
||||
var totalEnqueued = _telemetrySpool.LastAssignedItemId + 1; // -1 sentinel → 0
|
||||
|
||||
data["spool_pending_item_count"] = pendingItemCount;
|
||||
data["spool_pending_bytes"] = pendingBytes;
|
||||
data["spool_peak_pending_item_count"] = peakPending;
|
||||
data["spool_file_size_bytes"] = spoolFileSizeBytes;
|
||||
data["spool_total_enqueued_count"] = totalEnqueued;
|
||||
@@ -194,20 +199,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
});
|
||||
}
|
||||
|
||||
// One-shot pressure event \u2014 fires once per session when the spool grows past
|
||||
// either threshold. ImmediateUpload=true so it shows up promptly on the
|
||||
// backend without waiting for the next batch drain.
|
||||
// One-shot pressure event \u2014 fires once per session when the pending backlog
|
||||
// grows past either threshold. ImmediateUpload=true so it shows up promptly on
|
||||
// the backend without waiting for the next batch drain.
|
||||
if (_telemetrySpool != null
|
||||
&& (pendingItemCount > PressurePendingItemThreshold
|
||||
|| spoolFileSizeBytes > PressureFileSizeBytesThreshold)
|
||||
|| pendingBytes > PressurePendingBytesThreshold)
|
||||
&& Interlocked.CompareExchange(ref _pressureEmitted, 1, 0) == 0)
|
||||
{
|
||||
var pressureData = new Dictionary<string, object>(capacity: 8, StringComparer.Ordinal)
|
||||
{
|
||||
{ "pendingItemCount", pendingItemCount },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes },
|
||||
{ "pendingBytes", pendingBytes },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes }, // informational only, not a trigger
|
||||
{ "pendingThreshold", PressurePendingItemThreshold },
|
||||
{ "fileSizeThresholdBytes", PressureFileSizeBytesThreshold },
|
||||
{ "pendingBytesThreshold", PressurePendingBytesThreshold },
|
||||
{ "totalEnqueuedCount", _telemetrySpool.LastAssignedItemId + 1 },
|
||||
{ "lastUploadedItemId", _telemetrySpool.LastUploadedItemId },
|
||||
{ "ImmediateUpload", true }
|
||||
@@ -222,15 +228,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "AgentSelfMetricsCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount}, " +
|
||||
$"fileBytes={spoolFileSizeBytes} \u2014 upload may be stalled or session " +
|
||||
$"is unusually long.",
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount} items / " +
|
||||
$"{pendingBytes} bytes not yet uploaded \u2014 upload is stalled or " +
|
||||
$"falling behind.",
|
||||
Data = pressureData
|
||||
});
|
||||
|
||||
Logger.Warning(
|
||||
$"AgentSelfMetricsCollector: spool pressure detected " +
|
||||
$"(pending={pendingItemCount}, fileBytes={spoolFileSizeBytes}). " +
|
||||
$"(pending={pendingItemCount}, pendingBytes={pendingBytes}). " +
|
||||
$"Emitted spool_pressure_detected (one-shot per session).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,12 +404,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
if (state.AccountSetupEnteredUtc == null) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
// anchor nor an ESP final exit is guaranteed to exist — its dead-end zone
|
||||
// begins at the real-user desktop instead (from there only Hello or a deadline
|
||||
// can move the session). Normally the DevicePrepCompletion backstop armed at
|
||||
// desktop arrival satisfies the invariant; this branch makes a regression visible.
|
||||
var isDevicePreparation = state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation;
|
||||
if (state.AccountSetupEnteredUtc == null && !isDevicePreparation) return false;
|
||||
|
||||
var deadEndZoneEntered =
|
||||
state.EspAdvisoryFailureRecordedUtc != null
|
||||
|| (state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value);
|
||||
|| (state.AccountSetupEnteredUtc != null
|
||||
&& state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value)
|
||||
|| (isDevicePreparation && state.DesktopArrivedUtc != null);
|
||||
if (!deadEndZoneEntered) return false;
|
||||
|
||||
foreach (var deadline in state.Deadlines)
|
||||
@@ -431,8 +440,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
["stage"] = state.Stage.ToString(),
|
||||
["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture),
|
||||
["signalOrdinal"] = signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc!.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture),
|
||||
// Null on the WDP branch of IsParkedWithoutDeadline (no ESP anchor there).
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc?.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture) ?? "",
|
||||
["signalsSeen"] = string.Join(",", census.SignalsSeen),
|
||||
["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)),
|
||||
["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture),
|
||||
|
||||
@@ -109,6 +109,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var hosts = new List<ICollectorHost>();
|
||||
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
|
||||
|
||||
// WDP scenario gate (afee7ae0, 2026-08-18): Device Preparation has no ESP, so the
|
||||
// ESP-only machinery below (provisioning-status registry watcher, ESP policy-provider
|
||||
// stall tripwire, Autopilot-channel noise) burns cycles on registry keys that never
|
||||
// exist there — or worse, wakes up on every WDP progress write. Gated on the
|
||||
// DETERMINISTIC WDP marker only (BootstrapperAgent ExecutionContext, present before
|
||||
// the agent starts); the CloudAssigned* fallback rules keep full Classic behavior.
|
||||
var isDevicePreparation = Security.EnrollmentRegistryDetector.IsDeterministicDevicePreparation();
|
||||
if (isDevicePreparation)
|
||||
{
|
||||
logger.Info("DefaultComponentFactory: Device Preparation session (deterministic marker) — ESP-only hosts and Autopilot-channel noise are gated");
|
||||
}
|
||||
|
||||
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
||||
|
||||
// Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had
|
||||
@@ -151,6 +163,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
||||
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
stateDirectory: _stateDirectory,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: () => imeLogHostRef?.AreUserEspAppsSettled() == true,
|
||||
// Liveness plan PR3: starved-apps probe over the same lazily-assigned IME host.
|
||||
starvedUserEspAppsProbe: () =>
|
||||
@@ -308,7 +321,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: collectors.StallProbeTraceIndices,
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
@@ -317,14 +331,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
|
||||
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
|
||||
// outside the StallProbe gate: the idle clock there resets on any session activity,
|
||||
// and the stall must accrue regardless of it.
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
// and the stall must accrue regardless of it. Not created on Device Preparation —
|
||||
// the EnrollmentStatusTracking key it polls every 60s is an ESP-only construct.
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
|
||||
// registry_app_state on real changes and app_state_reconciliation when the
|
||||
// registry outcome diverges from the IME-log-derived state (= built-in pattern-drift
|
||||
// alarm). Always-on observability host; the tracker probe is read-only.
|
||||
hosts.Add(new ImeRegistryAppStateHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
trackerStateProbe: () => imeLogHost.AllKnownPackageStates));
|
||||
|
||||
// Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR)
|
||||
// across agent restarts and emits os_build_changed when it differs (session 7443317c:
|
||||
|
||||
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int modernDeploymentBackfillLookbackMinutes,
|
||||
int[]? modernDeploymentHarmlessEventIds,
|
||||
string stateDirectory,
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool>? userEspAppsSettledProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? starvedUserEspAppsProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
@@ -89,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: userEspAppsSettledProbe,
|
||||
starvedUserEspAppsProbe: starvedUserEspAppsProbe,
|
||||
packageStatesProbe: packageStatesProbe);
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
|
||||
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> on the IME root — all three
|
||||
/// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports)
|
||||
/// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback
|
||||
/// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes
|
||||
/// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode,
|
||||
/// announced once via <c>collector_degraded</c>). Always-on observability host, no config
|
||||
/// gate — precedent: <see cref="EspPolicyProviderStallHost"/>.
|
||||
/// </summary>
|
||||
internal sealed class ImeRegistryAppStateHost : ICollectorHost
|
||||
{
|
||||
public string Name => "ImeRegistryAppState";
|
||||
|
||||
private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ImeRegistryAppStateObserver _observer;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger? _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
|
||||
private RegistryWatcher? _watcher;
|
||||
private Timer? _debounceTimer;
|
||||
private Timer? _periodicTimer;
|
||||
private int _stopped;
|
||||
|
||||
public ImeRegistryAppStateHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger? logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Func<IReadOnlyList<AppPackageState>>? trackerStateProbe)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_logger = logger;
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// Baseline immediately: pre-existing registry state must be captured silently
|
||||
// BEFORE the first change edge, or stale entries would replay as fresh events.
|
||||
_observer.Tick("baseline");
|
||||
|
||||
try
|
||||
{
|
||||
_watcher = new RegistryWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
ImeRegistryAppStateObserver.ImeRootKeyPath,
|
||||
watchSubtree: true,
|
||||
view: RegistryView.Registry64,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.Name
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
trace: msg => _logger?.Debug($"ImeRegistryAppState watcher: {msg}"));
|
||||
_watcher.Changed += (_, __) => ScheduleDebouncedTick();
|
||||
_watcher.Error += (_, ex) =>
|
||||
{
|
||||
_logger?.Warning($"ImeRegistryAppState watcher error (poll-only fallback continues): {ex.Message}");
|
||||
};
|
||||
_watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Watcher arm failure degrades to poll-only (periodic tick below) — announce once.
|
||||
_logger?.Warning($"ImeRegistryAppState: watcher arm failed, running poll-only: {ex.Message}");
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex);
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
}
|
||||
|
||||
_periodicTimer = new Timer(_ => SafeTick("periodic"), null, PeriodicInterval, PeriodicInterval);
|
||||
_logger?.Info($"ImeRegistryAppStateHost started (watcher={(_watcher != null ? "armed" : "poll-only")})");
|
||||
}
|
||||
|
||||
private void ScheduleDebouncedTick()
|
||||
{
|
||||
if (Volatile.Read(ref _stopped) != 0) return;
|
||||
// One trailing-edge tick per change burst; RegistryWatcher coalesces anyway and the
|
||||
// observer snapshot-diffs, so collapsing bursts loses nothing.
|
||||
var timer = _debounceTimer;
|
||||
if (timer == null)
|
||||
{
|
||||
var created = new Timer(_ => SafeTick("registry_change"), null, DebounceDelay, Timeout.InfiniteTimeSpan);
|
||||
if (Interlocked.CompareExchange(ref _debounceTimer, created, null) != null)
|
||||
created.Dispose();
|
||||
else
|
||||
return;
|
||||
timer = _debounceTimer;
|
||||
}
|
||||
try { timer?.Change(DebounceDelay, Timeout.InfiniteTimeSpan); }
|
||||
catch (ObjectDisposedException) { /* stopping */ }
|
||||
}
|
||||
|
||||
private void SafeTick(string reason)
|
||||
{
|
||||
if (Volatile.Read(ref _stopped) != 0) return;
|
||||
try
|
||||
{
|
||||
_observer.Tick(reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Observer.Tick is itself fail-soft; this is the last-resort belt.
|
||||
_logger?.Debug($"ImeRegistryAppState tick failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _stopped, 1) != 0) return;
|
||||
|
||||
// Never Stop() the watcher from inside its own callback — we're outside here,
|
||||
// but RequestStop first keeps the contract obvious.
|
||||
try { _watcher?.RequestStop(); } catch { /* fail-soft */ }
|
||||
try { _watcher?.Dispose(); } catch { /* fail-soft */ }
|
||||
_watcher = null;
|
||||
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = null;
|
||||
_periodicTimer?.Dispose();
|
||||
_periodicTimer = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int[]? traceIndices,
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds)
|
||||
int[]? harmlessModernDeploymentEventIds,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -72,7 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: traceIndices ?? new[] { 2 },
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
|
||||
@@ -170,33 +170,78 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>
|
||||
/// Classifies the Autopilot flow based on the <c>AutopilotSettings</c> registry:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CloudAssignedDeviceRegistration == 2</c> → <c>"v2"</c> (Windows Device Preparation).</item>
|
||||
/// <item><c>CloudAssignedEspEnabled == 0</c> → <c>"v2"</c> (no ESP, WDP indicator).</item>
|
||||
/// <item><c>DevicePreparation\BootstrapperAgent</c> SUBKEY with a non-empty
|
||||
/// <c>ExecutionContext</c> → <c>"v2"</c>. This is the Device Preparation policy
|
||||
/// orchestration tree (ExecutionContext JSON carries the WDP policyId and the
|
||||
/// provider batch list). Field-verified 2026-08-17: present on a WDP device,
|
||||
/// absent on classic v1 — which only has ESP category VALUES named
|
||||
/// <c>DevicePreparation.*</c> on the AutopilotSettings key itself, hence the
|
||||
/// subkey probe, never a value-name probe.</item>
|
||||
/// <item><c>CloudAssignedDeviceRegistration == 2</c> → <c>"v2"</c> (legacy signal).</item>
|
||||
/// <item><c>CloudAssignedEspEnabled == 0</c> → <c>"v2"</c> (no ESP, legacy signal).
|
||||
/// Both CloudAssigned* values are profile-derived and structurally ABSENT on WDP
|
||||
/// (no Autopilot profile), so they never fire there — kept for defence in depth.</item>
|
||||
/// <item>Anything else → <c>"v1"</c> (Classic Autopilot / ESP).</item>
|
||||
/// </list>
|
||||
/// Defaults to <c>"v1"</c> on any error so a flaky registry does not misclassify a
|
||||
/// classic session as WDP.
|
||||
/// </summary>
|
||||
public static string DetectEnrollmentType()
|
||||
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
|
||||
|
||||
/// <summary>
|
||||
/// Rule string returned when the deterministic Device Preparation marker fired
|
||||
/// (the WDP policy orchestration tree — see <see cref="DetectEnrollmentTypeWithRule"/>).
|
||||
/// </summary>
|
||||
public const string DevicePreparationExecutionContextRule =
|
||||
@"DevicePreparation\BootstrapperAgent\ExecutionContext present";
|
||||
|
||||
/// <summary>
|
||||
/// True only when the deterministic Device Preparation marker (BootstrapperAgent
|
||||
/// ExecutionContext) is present. Scenario gates that DISABLE Autopilot/ESP-only
|
||||
/// machinery must use this instead of the "v2" string: the CloudAssigned* fallback
|
||||
/// rules describe a real Autopilot profile (just without ESP) where that machinery
|
||||
/// still has data to observe — gates fail toward Classic behavior by design.
|
||||
/// </summary>
|
||||
public static bool IsDeterministicDevicePreparation()
|
||||
{
|
||||
var (enrollmentType, detectionRule) = DetectEnrollmentTypeWithRule();
|
||||
return enrollmentType == "v2" && detectionRule == DevicePreparationExecutionContextRule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
|
||||
/// enrollment_type_detected event can name its evidence instead of re-implementing the
|
||||
/// detection (the duplicate inline logic in DeviceInfoCollector missed the WDP subkey
|
||||
/// rule and kept reporting "v1" on Device Preparation devices — session a11102f4).
|
||||
/// </summary>
|
||||
public static (string enrollmentType, string detectionRule) DetectEnrollmentTypeWithRule()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var wdpKey = Registry.LocalMachine.OpenSubKey(
|
||||
AutopilotSettingsKey + @"\DevicePreparation\BootstrapperAgent"))
|
||||
{
|
||||
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
|
||||
if (!string.IsNullOrEmpty(executionContext))
|
||||
return ("v2", DevicePreparationExecutionContextRule);
|
||||
}
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
|
||||
{
|
||||
if (key == null) return "v1";
|
||||
if (key == null) return ("v1", "AutopilotSettings key missing");
|
||||
|
||||
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
|
||||
if (deviceReg == "2") return "v2";
|
||||
if (deviceReg == "2") return ("v2", "CloudAssignedDeviceRegistration=2");
|
||||
|
||||
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
|
||||
if (espEnabled == "0") return "v2";
|
||||
if (espEnabled == "0") return ("v2", "CloudAssignedEspEnabled=0");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to v1 default.
|
||||
}
|
||||
return "v1";
|
||||
return ("v1", "default (no v2 indicators)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
@@ -75,6 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
private readonly Action<string, string>? _prevOnImeTokenFailure;
|
||||
|
||||
// Our own delegate instances — stored once so Dispose can compare by reference.
|
||||
private readonly Action<string> _ourOnEspPhaseChanged;
|
||||
@@ -85,6 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
private readonly Action<string, string> _ourOnImeTokenFailure;
|
||||
|
||||
// Dedup state for DecisionSignals.
|
||||
private string? _lastEspPhase;
|
||||
@@ -97,6 +100,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// bootstrap marker line. Lets MCP report "which device runs which bootstrap version".
|
||||
private bool _bootstrapDetectedPosted;
|
||||
|
||||
// Cached EnterpriseDesktopAppManagement-CSP lookup for the IME install source — the
|
||||
// version event fires more than once per session (V1 parity, no dedup) and the registry
|
||||
// answer is stable per version, so the probe runs once per distinct version string.
|
||||
private string? _msiSourceCachedForVersion;
|
||||
private ImeMsiInstallSource _msiSourceCache;
|
||||
|
||||
// One-shot: historic script replay detected (a previous enrollment's IME log content
|
||||
// surviving on disk) — its script events are suppressed, and a single
|
||||
// historic_ime_replay_detected Info event explains the gap in the timeline.
|
||||
@@ -168,6 +177,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
|
||||
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
|
||||
_prevOnScriptStarted = _tracker.OnScriptStarted;
|
||||
_prevOnImeTokenFailure = _tracker.OnImeTokenFailure;
|
||||
|
||||
// Store our delegate instances once — implicit method-group conversions
|
||||
// create a new delegate each time, which would break Dispose's reference check.
|
||||
@@ -179,6 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
|
||||
_ourOnScriptCompleted = OnScriptCompleted;
|
||||
_ourOnScriptStarted = OnScriptStarted;
|
||||
_ourOnImeTokenFailure = OnImeTokenFailure;
|
||||
|
||||
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
|
||||
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
|
||||
@@ -188,6 +199,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
|
||||
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
|
||||
_tracker.OnScriptStarted = _ourOnScriptStarted;
|
||||
_tracker.OnImeTokenFailure = _ourOnImeTokenFailure;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -210,6 +222,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
|
||||
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
|
||||
_tracker.OnScriptStarted = _prevOnScriptStarted;
|
||||
if (ReferenceEquals(_tracker.OnImeTokenFailure, _ourOnImeTokenFailure))
|
||||
_tracker.OnImeTokenFailure = _prevOnImeTokenFailure;
|
||||
}
|
||||
|
||||
private void OnEspPhaseChanged(string phase)
|
||||
@@ -260,6 +274,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitScriptStarted(info);
|
||||
}
|
||||
|
||||
private void OnImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
_prevOnImeTokenFailure?.Invoke(errorCode, message);
|
||||
EmitImeTokenFailure(errorCode, message);
|
||||
}
|
||||
|
||||
internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase);
|
||||
internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted();
|
||||
internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) =>
|
||||
@@ -270,6 +290,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
internal void TriggerImeTokenFailureFromTest(string errorCode, string message) => EmitImeTokenFailure(errorCode, message);
|
||||
|
||||
/// <summary>
|
||||
/// Prefer the CMTrace log entry timestamp recorded by the most recent pattern match
|
||||
@@ -864,6 +885,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
data["patternId"] = patternId!;
|
||||
|
||||
// The EnterpriseDesktopAppManagement CSP records where it downloaded the IME MSI
|
||||
// from — attach that install source so the event answers "which endpoint delivered
|
||||
// exactly this build" (helps attribute bad builds to a CDN/AFD rollout ring).
|
||||
var source = ResolveMsiInstallSource(version);
|
||||
if (source.HasData)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(source.DownloadUrl)) data["msiDownloadUrl"] = source.DownloadUrl;
|
||||
if (!string.IsNullOrEmpty(source.ProductCode)) data["msiProductCode"] = source.ProductCode;
|
||||
if (!string.IsNullOrEmpty(source.ProductVersion)) data["msiProductVersion"] = source.ProductVersion;
|
||||
data["msiMatchedBy"] = source.MatchedByVersion ? "productVersion" : "fileName";
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
_post.Emit(
|
||||
@@ -874,7 +908,45 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: now);
|
||||
|
||||
// PR3-D4: fire-once and useful for compatibility-debugging — INFO level.
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
_logger?.Info(source.HasData && !string.IsNullOrEmpty(source.DownloadUrl)
|
||||
? $"ImeAdapter: IME agent version detected: {version} (CSP install source: {source.DownloadUrl})"
|
||||
: $"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
// IME token-acquisition failure ("Failed to get AAD token ... errorCode = N",
|
||||
// IntuneTokenManager). Previously we only matched the success line — an outage
|
||||
// (WAM broken, conditional access, network) was invisible while every check-in
|
||||
// silently did nothing. Tracker-side dedup: once per distinct errorCode.
|
||||
private void EmitImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["errorCode"] = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
data["logLine"] = message;
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
data["patternId"] = patternId!;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.ImeTokenFailure,
|
||||
source: SourceLabel,
|
||||
message: $"IME token acquisition failed (errorCode={data["errorCode"]})",
|
||||
data: data,
|
||||
severity: EventSeverity.Warning,
|
||||
occurredAtUtc: now);
|
||||
}
|
||||
|
||||
private ImeMsiInstallSource ResolveMsiInstallSource(string version)
|
||||
{
|
||||
if (_msiSourceCachedForVersion == version) return _msiSourceCache;
|
||||
_msiSourceCache = ImeMsiInstallSourceProbe.Read(version, _logger);
|
||||
_msiSourceCachedForVersion = version;
|
||||
return _msiSourceCache;
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
|
||||
+92
-4
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
@@ -56,6 +57,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
public sealed class EnrollmentTerminationHandler
|
||||
{
|
||||
private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — on DevicePreparation the IME user-context app sync starts only
|
||||
/// after desktop arrival and its log lines reach disk with 10-20s flush latency, so the
|
||||
/// dialog snapshot raced it and rendered an empty Applications section 9 seconds before
|
||||
/// the tracker learned about 12 pending apps. Bounded settle wait before the summary
|
||||
/// build; DevicePrep-only and only while the tracker has zero packages, so Classic (data
|
||||
/// long settled) and populated DevicePrep sessions pay nothing. Installs are never
|
||||
/// delayed — this only holds the dialog snapshot.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultDevicePrepAppSettleWait = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan AppSettlePollInterval = TimeSpan.FromMilliseconds(250);
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): poll cadence for the
|
||||
// active spool-empty drain. 50ms is short enough that we exit the bounded wait
|
||||
// within ~one cadence after the last upload is acknowledged, but long enough that
|
||||
@@ -90,6 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
private readonly AgentAnalyzerManager _analyzerManager;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly TimeSpan _lateEventGracePeriod;
|
||||
private readonly TimeSpan _devicePrepAppSettleWait;
|
||||
|
||||
// L4 (delta review 2026-07-02): epoch anchor for boot-time derivation. Captured as a
|
||||
// (monotonic tick, wall clock) pair at construction so the boot timestamp used by the
|
||||
@@ -113,7 +127,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
AgentAnalyzerManager analyzerManager = null,
|
||||
InformationalEventPost post = null,
|
||||
Action stopPeripheralCollectors = null,
|
||||
TimeSpan? lateEventGracePeriod = null)
|
||||
TimeSpan? lateEventGracePeriod = null,
|
||||
TimeSpan? devicePrepAppSettleWait = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_appTracking = appTracking ?? throw new ArgumentNullException(nameof(appTracking));
|
||||
@@ -126,6 +141,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
_post = post;
|
||||
_stopPeripheralCollectors = stopPeripheralCollectors;
|
||||
_lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
|
||||
_devicePrepAppSettleWait = devicePrepAppSettleWait ?? DefaultDevicePrepAppSettleWait;
|
||||
|
||||
_configuration = session.Configuration;
|
||||
_stateDirectory = session.StateDirectory;
|
||||
@@ -182,6 +198,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
else
|
||||
{
|
||||
// Session bc803955 — give the IME user-context app wave a bounded chance
|
||||
// to land BEFORE the dialog snapshot, the starved sweep and the
|
||||
// app_tracking_summary below all read package states. DevicePrep-only,
|
||||
// no-op when the tracker already has data.
|
||||
WaitForImeAppSettleIfDevicePrep(state);
|
||||
RunBuildAndLaunchDialog(state, args);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +228,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// Runs on every outcome; the live (esp_exited) path's dedupe set is
|
||||
// consulted so no app is reported twice. Promoted likely-stuck apps are
|
||||
// Error by now and excluded by the probe itself.
|
||||
EmitStarvedUserEspApps(args);
|
||||
EmitStarvedUserEspApps(state, args);
|
||||
|
||||
// Session 6b4993e5 / fc48c71a — on an ESP terminal Apps-subcategory failure,
|
||||
// name the in-flight device app(s) (e.g. stuck Downloading at 0%) as the likely
|
||||
@@ -378,6 +399,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded settle wait for the IME app wave on DevicePreparation sessions (see
|
||||
/// <see cref="DefaultDevicePrepAppSettleWait"/>). Returns immediately for every other
|
||||
/// scenario mode and whenever the tracker already reports packages. Poll is cheap
|
||||
/// (in-memory read-model access), so a fine 250ms interval keeps the early exit tight.
|
||||
/// </summary>
|
||||
private void WaitForImeAppSettleIfDevicePrep(DecisionState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state == null || _devicePrepAppSettleWait <= TimeSpan.Zero) return;
|
||||
if (state.ScenarioProfile.Mode != EnrollmentMode.DevicePreparation) return;
|
||||
|
||||
var packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0) return;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: DevicePreparation session with no IME app data yet — waiting up to " +
|
||||
$"{_devicePrepAppSettleWait.TotalSeconds:0}s for the user-context app wave before building the summary " +
|
||||
"(installs are not delayed; this only holds the dialog snapshot).");
|
||||
|
||||
var deadline = DateTime.UtcNow + _devicePrepAppSettleWait;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(AppSettlePollInterval);
|
||||
packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: IME app data arrived during settle wait ({packages.Count} package(s)).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("EnrollmentTerminationHandler: app settle wait elapsed without IME app data — summary reflects Office/CSP state only.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: app settle wait failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft read of the Office C2R lifecycle state persisted by
|
||||
/// <see cref="OfficeInstallDetector"/> — feeds the summary dialog's synthetic
|
||||
/// "Microsoft 365 Apps" row (Office CSP installs never flow through the IME Win32
|
||||
/// pipeline, so the package-state list cannot represent them).
|
||||
/// </summary>
|
||||
private OfficeInstallStateData TryLoadOfficeInstallState()
|
||||
{
|
||||
try { return new OfficeInstallStatePersistence(_stateDirectory, _logger).Load(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
try
|
||||
@@ -385,7 +459,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var packages = TryGetPackageStates();
|
||||
var timings = TryGetAppTimings();
|
||||
var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc, timings,
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc));
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc),
|
||||
officeState: TryLoadOfficeInstallState());
|
||||
SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
|
||||
|
||||
if (_configuration.ShowEnrollmentSummary)
|
||||
@@ -482,7 +557,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// wording; Failed/TimedOut keep the Warning + "starved" verdict.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EmitStarvedUserEspApps(EnrollmentTerminatedEventArgs args)
|
||||
private void EmitStarvedUserEspApps(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_post == null) return;
|
||||
|
||||
@@ -491,6 +566,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var starved = _appTracking.GetStarvedUserEspApps();
|
||||
if (starved == null || starved.Count == 0) return;
|
||||
|
||||
// Device Preparation has no ESP apps gate — installing after the desktop is
|
||||
// the DESIGNED order there (only DPP-policy apps run during provisioning, the
|
||||
// rest follow via IME). Reporting every pending app as "starved" turned a
|
||||
// normal WDP session into 10+ noise events (afee7ae0); the same per-app data
|
||||
// still reaches the backend via app_tracking_summary and final-status.json.
|
||||
if (state?.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation)
|
||||
{
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: {starved.Count} app(s) pending at termination on a " +
|
||||
"DevicePreparation session — expected post-desktop install order, app_install_starved sweep skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
var succeeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
|
||||
foreach (var app in starved)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
@@ -30,7 +31,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
IReadOnlyList<AppPackageState>? packageStates,
|
||||
DateTime agentStartTimeUtc,
|
||||
IReadOnlyDictionary<string, AppInstallTiming>? appTimings = null,
|
||||
DateTime? deviceBootUtc = null)
|
||||
DateTime? deviceBootUtc = null,
|
||||
OfficeInstallStateData? officeState = null)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
if (terminated == null) throw new ArgumentNullException(nameof(terminated));
|
||||
@@ -78,9 +80,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
PackageStatesByPhase = BuildPackageStatesByPhase(packageStates, timings),
|
||||
};
|
||||
|
||||
AddOfficeRow(status, officeState);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — the Office C2R install (Office CSP app type / M365 "CSP" package)
|
||||
/// never flows through the IME Win32 pipeline, so <see cref="BuildAppSummary"/> cannot see
|
||||
/// it and a DevicePreparation session whose only app activity is Office rendered
|
||||
/// "No application data available". Synthesize one package row from the
|
||||
/// <see cref="OfficeInstallDetector"/>'s persisted lifecycle state instead. Preinstalled
|
||||
/// is deliberately skipped — that state marks resident OEM/consumer Office, not an
|
||||
/// enrollment install.
|
||||
/// </summary>
|
||||
private static void AddOfficeRow(FinalStatus status, OfficeInstallStateData? officeState)
|
||||
{
|
||||
if (officeState == null) return;
|
||||
|
||||
string rowState;
|
||||
bool isCompleted = false, isError = false;
|
||||
switch (officeState.State)
|
||||
{
|
||||
case OfficeInstallStateData.StateActive:
|
||||
rowState = AppInstallationState.Installing.ToString();
|
||||
break;
|
||||
case OfficeInstallStateData.StateCompleted:
|
||||
rowState = AppInstallationState.Installed.ToString();
|
||||
isCompleted = true;
|
||||
break;
|
||||
case OfficeInstallStateData.StateFailed:
|
||||
rowState = AppInstallationState.Error.ToString();
|
||||
isError = true;
|
||||
isCompleted = true; // mirrors IsCompleted(): Error counts as terminal
|
||||
break;
|
||||
default:
|
||||
return; // Preinstalled / unknown — not an enrollment install
|
||||
}
|
||||
|
||||
double? duration = officeState.StartedAtUtc.HasValue && officeState.CompletedAtUtc.HasValue
|
||||
? Math.Max(0, (officeState.CompletedAtUtc.Value - officeState.StartedAtUtc.Value).TotalSeconds)
|
||||
: (double?)null;
|
||||
|
||||
var name = string.IsNullOrEmpty(officeState.VersionReached)
|
||||
? "Microsoft 365 Apps"
|
||||
: $"Microsoft 365 Apps ({officeState.VersionReached})";
|
||||
|
||||
var row = new FinalStatusPackageInfo
|
||||
{
|
||||
AppName = name,
|
||||
State = rowState,
|
||||
IsError = isError,
|
||||
IsCompleted = isCompleted,
|
||||
Targeted = AppTargeted.Device.ToString(),
|
||||
StartedAt = officeState.StartedAtUtc?.ToString("o"),
|
||||
CompletedAt = officeState.CompletedAtUtc?.ToString("o"),
|
||||
DurationSeconds = duration,
|
||||
};
|
||||
|
||||
var phaseKey = AppTargeted.Device.ToString();
|
||||
if (!status.PackageStatesByPhase.TryGetValue(phaseKey, out var bucket))
|
||||
{
|
||||
bucket = new List<FinalStatusPackageInfo>();
|
||||
status.PackageStatesByPhase[phaseKey] = bucket;
|
||||
}
|
||||
// Office installs during device prep / device ESP — before any IME-tracked app.
|
||||
bucket.Insert(0, row);
|
||||
|
||||
var summary = status.AppSummary;
|
||||
summary.TotalApps++;
|
||||
if (isCompleted) summary.CompletedApps++;
|
||||
if (isError)
|
||||
{
|
||||
summary.ErrorCount++;
|
||||
summary.DeviceErrors++;
|
||||
}
|
||||
if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0;
|
||||
summary.AppsByPhase[phaseKey] = cnt + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema 2 — derives a human-readable explanation rendered as the dialog's failure
|
||||
/// banner. Returns <c>null</c> for successful terminal outcomes (succeeded /
|
||||
|
||||
@@ -59,10 +59,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
int PeakPendingItemCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized on-disk byte size of the pending (not-yet-uploaded) tail — the actual
|
||||
/// upload backlog. Unlike <see cref="SpoolFileSizeBytes"/>, which grows append-only
|
||||
/// over the whole session regardless of upload progress, this shrinks back to 0 as
|
||||
/// <see cref="MarkUploaded"/> advances the cursor, so it is the correct input for
|
||||
/// back-pressure thresholds.
|
||||
/// </summary>
|
||||
long PendingBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current size of the on-disk <c>spool.jsonl</c> file in bytes. Returns 0 when the
|
||||
/// file does not yet exist. Cheap (single <c>FileInfo.Length</c> call) and safe to
|
||||
/// poll from periodic collectors.
|
||||
/// poll from periodic collectors. Append-only cumulative — informational only, NOT a
|
||||
/// backlog measure (use <see cref="PendingBytes"/> for that).
|
||||
/// </summary>
|
||||
long SpoolFileSizeBytes { get; }
|
||||
|
||||
|
||||
@@ -56,9 +56,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Populated from disk in the ctor and kept in sync by Enqueue / MarkUploaded. Peek
|
||||
// reads from here so the periodic drain loop is O(batchSize) instead of O(N) per
|
||||
// call. Disk JSONL stays the source of truth — the cache is rebuilt on every
|
||||
// process start.
|
||||
private readonly LinkedList<TelemetryItem> _pending = new LinkedList<TelemetryItem>();
|
||||
// process start. Each entry carries its on-disk line length (incl. trailing \n) so
|
||||
// MarkUploaded can decrement _pendingBytes exactly.
|
||||
private readonly LinkedList<PendingEntry> _pending = new LinkedList<PendingEntry>();
|
||||
private int _peakPendingCount;
|
||||
private long _pendingBytes;
|
||||
|
||||
private readonly struct PendingEntry
|
||||
{
|
||||
public PendingEntry(TelemetryItem item, int byteLength)
|
||||
{
|
||||
Item = item;
|
||||
ByteLength = byteLength;
|
||||
}
|
||||
|
||||
public TelemetryItem Item { get; }
|
||||
public int ByteLength { get; }
|
||||
}
|
||||
|
||||
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
|
||||
{
|
||||
@@ -105,6 +119,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
get { lock (_lock) return _peakPendingCount; }
|
||||
}
|
||||
|
||||
public long PendingBytes
|
||||
{
|
||||
get { lock (_lock) return _pendingBytes; }
|
||||
}
|
||||
|
||||
public long SpoolFileSizeBytes
|
||||
{
|
||||
get
|
||||
@@ -171,7 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
|
||||
_lastAssignedItemId = itemId;
|
||||
_pending.AddLast(item);
|
||||
_pending.AddLast(new PendingEntry(item, bytes.Length));
|
||||
_pendingBytes += bytes.Length;
|
||||
if (_pending.Count > _peakPendingCount)
|
||||
_peakPendingCount = _pending.Count;
|
||||
|
||||
@@ -209,7 +229,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
var node = _pending.First;
|
||||
while (node != null && batch.Count < max)
|
||||
{
|
||||
batch.Add(node.Value);
|
||||
batch.Add(node.Value.Item);
|
||||
node = node.Next;
|
||||
}
|
||||
return batch;
|
||||
@@ -233,25 +253,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Trim the in-memory tail. The list is in TelemetryItemId-monotonic order
|
||||
// (Enqueue only appends), so we can drop from the front until the first
|
||||
// remaining item is past the cursor.
|
||||
while (_pending.First != null && _pending.First.Value.TelemetryItemId <= upToItemIdInclusive)
|
||||
while (_pending.First != null && _pending.First.Value.Item.TelemetryItemId <= upToItemIdInclusive)
|
||||
{
|
||||
_pendingBytes -= _pending.First.Value.ByteLength;
|
||||
_pending.RemoveFirst();
|
||||
}
|
||||
|
||||
// _pendingBytes drives the spool-pressure trigger: negative drift would
|
||||
// silently disarm it, a stale positive on an empty list would pin it on.
|
||||
// The list is authoritative — on drift, recompute from it and log loudly.
|
||||
if (_pendingBytes < 0 || (_pending.Count == 0 && _pendingBytes != 0))
|
||||
{
|
||||
long recomputed = 0;
|
||||
foreach (var entry in _pending) recomputed += entry.ByteLength;
|
||||
_logger?.Warning(
|
||||
$"TelemetrySpool: pending-bytes accounting drifted to {_pendingBytes} with {_pending.Count} pending items after MarkUploaded({upToItemIdInclusive}) — recomputed to {recomputed}.");
|
||||
_pendingBytes = recomputed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-pass scan of <c>spool.jsonl</c>: returns the highest <c>TelemetryItemId</c>
|
||||
/// found AND populates <see cref="_pending"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor — replaces the
|
||||
/// previous per-Peek <c>File.ReadAllLines</c> hot path.
|
||||
/// Single-pass streaming scan of <c>spool.jsonl</c> (<c>File.ReadLines</c> — never
|
||||
/// materializes the whole file): returns the highest <c>TelemetryItemId</c> found AND
|
||||
/// populates <see cref="_pending"/> / <see cref="_pendingBytes"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor.
|
||||
/// </summary>
|
||||
private long ScanSpoolAndRehydratePending()
|
||||
{
|
||||
if (!File.Exists(_spoolPath)) return -1;
|
||||
|
||||
long highest = -1;
|
||||
foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8))
|
||||
foreach (var line in File.ReadLines(_spoolPath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
TelemetryItem item;
|
||||
@@ -270,7 +303,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (item.TelemetryItemId > highest) highest = item.TelemetryItemId;
|
||||
if (item.TelemetryItemId > _lastUploadedItemId)
|
||||
{
|
||||
_pending.AddLast(item);
|
||||
// +1 = the trailing \n Enqueue writes after every line, so the rehydrated
|
||||
// byte count matches what Enqueue would have accumulated.
|
||||
var byteLength = Encoding.UTF8.GetByteCount(line) + 1;
|
||||
_pending.AddLast(new PendingEntry(item, byteLength));
|
||||
_pendingBytes += byteLength;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// cleanup and exit.</item>
|
||||
/// <item><b>Session-age emergency break</b> — absolute cap (<c>AbsoluteMaxSessionHours</c>)
|
||||
/// across restarts prevents zombie agents from persisting forever.</item>
|
||||
/// <item><b>Crash log writer</b> — writes a last-resort crash_*.log next to the agent log
|
||||
/// <item><b>Crash log writer</b> — writes a last-resort crash-*.log next to the agent log
|
||||
/// directory when an uncaught exception escapes <c>Main</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -34,7 +34,10 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
internal const string CleanExitMarkerFileName = "clean-exit.marker";
|
||||
internal const string EnrollmentCompleteMarkerFileName = "enrollment-complete.marker";
|
||||
private const string CrashLogPrefix = "crash_";
|
||||
private const string CrashLogPrefix = "crash-";
|
||||
// Pre-rename agents (< 2026-08-18) wrote crash_*.log; still detected once so an
|
||||
// in-place self-update does not lose a pending crash classification.
|
||||
private const string LegacyCrashLogPrefix = "crash_";
|
||||
|
||||
// ----------------------------------------------------------------- Multi-instance guard
|
||||
|
||||
@@ -102,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>clean-exit.marker</c> + <c>crash_*.log</c> + session files + (if needed) the
|
||||
/// Reads <c>clean-exit.marker</c> + <c>crash-*.log</c> + session files + (if needed) the
|
||||
/// Windows event log to classify the previous exit. Deletes the marker and crash logs so
|
||||
/// the next cycle starts fresh. Legacy parity — same rules, same outputs.
|
||||
/// </summary>
|
||||
@@ -114,6 +117,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var hadCleanExit = File.Exists(cleanExitMarker);
|
||||
var crashLogs = Directory.Exists(logDirectory)
|
||||
? Directory.GetFiles(logDirectory, CrashLogPrefix + "*.log")
|
||||
.Concat(Directory.GetFiles(logDirectory, LegacyCrashLogPrefix + "*.log"))
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
if (hadCleanExit)
|
||||
@@ -406,7 +411,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.log");
|
||||
var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
|
||||
File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}");
|
||||
}
|
||||
catch { /* nowhere left to log */ }
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
"IntuneManagementExtension-????????-??????.log",
|
||||
"AppWorkload.log",
|
||||
"AppWorkload-????????-??????.log",
|
||||
"AppActionProcessor.log",
|
||||
"AppActionProcessor-????????-??????.log",
|
||||
"AgentExecutor.log",
|
||||
"AgentExecutor-????????-??????.log",
|
||||
"HealthScripts.log",
|
||||
|
||||
@@ -172,12 +172,13 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
|
||||
|
||||
// Two-tier runtime-launch fallback chain:
|
||||
// Two-tier runtime-launch fallback chain (order is token-aware, see below):
|
||||
//
|
||||
// 1. WMI Win32_Process.Create (first choice). Spawns the process from
|
||||
// the WBEM service context, outside the install-mode process tree
|
||||
// and outside Task Scheduler's queue (which defers /Run on battery
|
||||
// / OOBE — observed WinPE 2026-05-04, project_v2_install_runtime_handoff.md).
|
||||
// 1. WMI Win32_Process.Create (first choice on a full token). Spawns the
|
||||
// process from the WBEM service context, outside the install-mode
|
||||
// process tree and outside Task Scheduler's queue (which defers /Run
|
||||
// on battery / OOBE — observed WinPE 2026-05-04,
|
||||
// project_v2_install_runtime_handoff.md).
|
||||
// 2. schtasks /Run on the registered BootTrigger task (fallback).
|
||||
// Used when WMI is blocked, typically Defender ASR rule
|
||||
// d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations
|
||||
@@ -190,6 +191,18 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// the runtime via the Task Scheduler service — different
|
||||
// ATT&CK signature than either WMI or a manual schtasks /Run.
|
||||
//
|
||||
// Token-aware ordering (2026-08-17): WMI duplicates the CALLER's token onto
|
||||
// the new process. MDM-originated install chains (Bootstrap MSI via
|
||||
// EnterpriseDesktopAppManagement CSP → msiexec) carry a SYSTEM token with
|
||||
// privileges REMOVED (SeTimeZonePrivilege, SeSystemEnvironmentPrivilege) —
|
||||
// a WMI-launched runtime then runs degraded for its whole life (tzutil
|
||||
// exit 5, firmware reads privilege_denied). The Task Scheduler service
|
||||
// starts the task with a fresh full SYSTEM token, so on a stripped token
|
||||
// the order flips: schtasks /Run first (verified via process poll, since
|
||||
// /Run only reports "queued"), WMI as degraded-but-alive fallback. The
|
||||
// battery/OOBE queue-defer that motivated WMI-first is mitigated by the
|
||||
// hardened task XML (PR2: battery limits off, StartWhenAvailable, PT0S).
|
||||
//
|
||||
// Critical contract: --install returns 0 if deployment + task creation
|
||||
// succeeded, even if both immediate launch paths failed. Returning 1
|
||||
// here would make the bootstrap script throw, the next IME run would
|
||||
@@ -197,11 +210,27 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// sit without a runtime until manual intervention. The bootstrap
|
||||
// script's Get-Process probe is the canonical "no runtime came up"
|
||||
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
var launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
RuntimeLaunchResult launch;
|
||||
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
|
||||
{
|
||||
logger.Warning(
|
||||
"Install-mode process token does not hold SeTimeZonePrivilege — privilege-stripped " +
|
||||
"caller chain (typical: MDM LOB MSI install). WMI Win32_Process.Create would duplicate " +
|
||||
"this token onto the runtime, so the Scheduled Task launch (fresh full SYSTEM token from " +
|
||||
"the Task Scheduler service) is preferred for this install.");
|
||||
launch = DecideRestrictedTokenLaunchOutcome(
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger),
|
||||
verifyRuntimePid: () => WaitForDetachedRuntimePid(targetExePath, logger),
|
||||
tryWmi: () => TryStartRuntimeViaWmi(targetExePath, logger));
|
||||
}
|
||||
else
|
||||
{
|
||||
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
|
||||
launch = DecideRuntimeLaunchOutcome(
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
wmiPid: wmi.Pid,
|
||||
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
|
||||
}
|
||||
|
||||
switch (launch.Method)
|
||||
{
|
||||
@@ -211,7 +240,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
break;
|
||||
case RuntimeLaunchMethod.Schtasks:
|
||||
logger.Info(
|
||||
$"Runtime process queued via schtasks /Run fallback on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
$"Runtime start via schtasks /Run on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
|
||||
break;
|
||||
case RuntimeLaunchMethod.Deferred:
|
||||
logger.Warning(
|
||||
@@ -629,11 +658,113 @@ namespace AutopilotMonitor.Agent.V2
|
||||
diagnostic: $"Both immediate-launch paths failed; deferring runtime start to BootTrigger Scheduled Task on next reboot. {wmiHint} schtasks /Run exit={schtasksExit}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricted-token variant of the launch orchestration — used when the install-mode
|
||||
/// process token does not hold <c>SeTimeZonePrivilege</c> (privilege-stripped MDM/MSI
|
||||
/// caller chain, observed 2026-08-17). WMI would duplicate the stripped token onto the
|
||||
/// runtime, so the order flips: Scheduled Task first (fresh full SYSTEM token from the
|
||||
/// Task Scheduler service), WMI only as degraded-but-alive fallback.
|
||||
/// <para>
|
||||
/// Contract:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Invoke <paramref name="trySchtasks"/>; on exit 0 invoke <paramref name="verifyRuntimePid"/> (schtasks /Run only reports "queued", never "launched"). A verified pid returns <see cref="RuntimeLaunchMethod.Schtasks"/> without touching WMI.</description></item>
|
||||
/// <item><description>schtasks failed or no process appeared → invoke <paramref name="tryWmi"/>; a valid pid returns <see cref="RuntimeLaunchMethod.Wmi"/> with a diagnostic warning that the runtime runs on the restricted token.</description></item>
|
||||
/// <item><description>Both paths fail → <see cref="RuntimeLaunchMethod.Deferred"/>; the BootTrigger start on next reboot gets a full token anyway.</description></item>
|
||||
/// <item><description><paramref name="verifyRuntimePid"/> is only invoked after a successful schtasks queue — it costs up to the full poll window.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static RuntimeLaunchResult DecideRestrictedTokenLaunchOutcome(
|
||||
Func<int> trySchtasks,
|
||||
Func<int> verifyRuntimePid,
|
||||
Func<WmiLaunchAttempt> tryWmi)
|
||||
{
|
||||
if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks));
|
||||
if (verifyRuntimePid == null) throw new ArgumentNullException(nameof(verifyRuntimePid));
|
||||
if (tryWmi == null) throw new ArgumentNullException(nameof(tryWmi));
|
||||
|
||||
var schtasksExit = trySchtasks();
|
||||
if (schtasksExit == 0)
|
||||
{
|
||||
var pid = verifyRuntimePid();
|
||||
if (pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Schtasks,
|
||||
pid: pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: 0,
|
||||
diagnostic: $"Runtime launched via Scheduled Task (restricted install token — task provides a full SYSTEM token). Verified PID={pid}.");
|
||||
}
|
||||
}
|
||||
|
||||
var schtasksHint = schtasksExit == 0
|
||||
? "schtasks /Run was queued but no runtime process appeared within the verification window."
|
||||
: $"schtasks /Run exit={schtasksExit}.";
|
||||
|
||||
var wmi = tryWmi();
|
||||
if (wmi.ReturnValue == 0u && wmi.Pid > 0)
|
||||
{
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Wmi,
|
||||
pid: wmi.Pid,
|
||||
wmiReturnValue: 0u,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Runtime launched via WMI fallback ON THE RESTRICTED TOKEN — privileged features " +
|
||||
"(timezone auto-set, firmware reads) stay degraded until the next BootTrigger start. " +
|
||||
$"{schtasksHint} PID={wmi.Pid}.");
|
||||
}
|
||||
|
||||
return new RuntimeLaunchResult(
|
||||
method: RuntimeLaunchMethod.Deferred,
|
||||
pid: 0,
|
||||
wmiReturnValue: wmi.ReturnValue,
|
||||
schtasksExitCode: schtasksExit,
|
||||
diagnostic: "Restricted-token launch: both Scheduled Task and WMI paths failed; deferring to " +
|
||||
$"BootTrigger on next reboot (full token there). {schtasksHint} WMI returnValue={wmi.ReturnValue}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls for the detached runtime process after a <c>schtasks /Run</c> queue — same
|
||||
/// 10s / 500ms contract as the bootstrap script's Get-Process probe. The install-mode
|
||||
/// process shares the runtime's exe name, so the own pid is excluded; a pre-existing
|
||||
/// foreign runtime cannot be running here because install mode bails earlier on the
|
||||
/// Deployed marker.
|
||||
/// </summary>
|
||||
private static int WaitForDetachedRuntimePid(string exePath, AgentLogger logger)
|
||||
{
|
||||
var processName = Path.GetFileNameWithoutExtension(exePath);
|
||||
var ownPid = Process.GetCurrentProcess().Id;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = Process.GetProcessesByName(processName);
|
||||
try
|
||||
{
|
||||
var runtime = candidates.FirstOrDefault(p => p.Id != ownPid);
|
||||
if (runtime != null) return runtime.Id;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in candidates) p.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Runtime verification poll failed transiently: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
System.Threading.Thread.Sleep(500);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single WMI <c>Win32_Process.Create</c> attempt. <see cref="Pid"/>
|
||||
/// is <c>0</c> on any non-zero <see cref="ReturnValue"/>.
|
||||
/// </summary>
|
||||
private readonly struct WmiLaunchAttempt
|
||||
internal readonly struct WmiLaunchAttempt
|
||||
{
|
||||
public WmiLaunchAttempt(uint returnValue, int pid)
|
||||
{
|
||||
|
||||
@@ -306,7 +306,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var (enrollmentType, detectionRule) = EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
|
||||
// High-confidence WDP seed only for the deterministic marker — the
|
||||
// CloudAssigned* fallback rules keep the Medium seed (see the payload
|
||||
// key's doc on SignalPayloadKeys.EnrollmentTypeDeterministic).
|
||||
var deterministicWdp = enrollmentType == "v2"
|
||||
&& detectionRule == EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
@@ -314,6 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["enrollmentTypeDeterministic"] = deterministicWdp ? "true" : "false",
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether the current process token HOLDS a privilege — present in the token at
|
||||
/// all, enabled or disabled. This is different from the enable-on-demand pattern in
|
||||
/// <c>UefiSecureBootCertReader</c>: a privilege that was REMOVED from the token cannot be
|
||||
/// re-enabled with <c>AdjustTokenPrivileges</c> by anyone, including SYSTEM.
|
||||
/// <para>
|
||||
/// Why install mode needs this: MDM-originated install chains (EnterpriseDesktopAppManagement
|
||||
/// CSP → msiexec → bootstrap runner) carry a SYSTEM token with several privileges removed —
|
||||
/// observed 2026-08-17 with the Bootstrap MSI: SeTimeZonePrivilege and
|
||||
/// SeSystemEnvironmentPrivilege were gone, so tzutil exited 5 (ACCESS_DENIED) and firmware
|
||||
/// reads reported privilege_denied. WMI <c>Win32_Process.Create</c> duplicates the CALLER's
|
||||
/// token onto the new process, so the restriction propagates to the runtime; the Task
|
||||
/// Scheduler service, by contrast, starts the task with a fresh full SYSTEM token. The
|
||||
/// launch orchestration therefore needs to know up front whether its own token is stripped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft: every API failure reports "held" so the default (WMI-first) launch path is
|
||||
/// kept — a probe defect must never change behavior for the healthy platform-script chain.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TokenPrivilegeProbe
|
||||
{
|
||||
public const string TimeZonePrivilege = "SeTimeZonePrivilege";
|
||||
|
||||
public static bool IsPrivilegeHeld(string privilegeName)
|
||||
{
|
||||
try
|
||||
{
|
||||
NativeMethods.LUID target;
|
||||
if (!NativeMethods.LookupPrivilegeValueW(null, privilegeName, out target))
|
||||
return true;
|
||||
|
||||
SafeTokenHandle token;
|
||||
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
|
||||
NativeMethods.TOKEN_QUERY, out token))
|
||||
{
|
||||
if (token != null) token.Dispose();
|
||||
return true;
|
||||
}
|
||||
|
||||
using (token)
|
||||
{
|
||||
uint required;
|
||||
NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
IntPtr.Zero, 0, out required);
|
||||
if (required == 0 || required > 64 * 1024)
|
||||
return true;
|
||||
|
||||
var buffer = Marshal.AllocHGlobal((int)required);
|
||||
try
|
||||
{
|
||||
if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
|
||||
buffer, required, out required))
|
||||
return true;
|
||||
|
||||
// TOKEN_PRIVILEGES layout: DWORD PrivilegeCount, then PrivilegeCount ×
|
||||
// LUID_AND_ATTRIBUTES { LUID (uint Low, int High), DWORD Attributes } = 12 bytes.
|
||||
var count = Marshal.ReadInt32(buffer);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = 4 + i * 12;
|
||||
var low = unchecked((uint)Marshal.ReadInt32(buffer, entry));
|
||||
var high = Marshal.ReadInt32(buffer, entry + 4);
|
||||
if (low == target.LowPart && high == target.HighPart)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
public SafeTokenHandle() : base(true) { }
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
return NativeMethods.CloseHandle(handle);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
public const uint TOKEN_QUERY = 0x0008;
|
||||
public const int TokenPrivileges = 3; // TOKEN_INFORMATION_CLASS.TokenPrivileges
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct LUID
|
||||
{
|
||||
public uint LowPart;
|
||||
public int HighPart;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool LookupPrivilegeValueW(string lpSystemName, string lpName, out LUID lpLuid);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetTokenInformation(SafeTokenHandle tokenHandle, int tokenInformationClass,
|
||||
IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool CloseHandle(IntPtr handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
|
||||
#
|
||||
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
|
||||
# script/app batches during Autopilot Device Preparation). This runner downloads the
|
||||
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
|
||||
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
|
||||
# server-updatable without re-shipping the MSI.
|
||||
#
|
||||
# Fail-soft by design: the runner always exits 0 so a transient network failure never
|
||||
# marks the LOB install as failed; diagnostics go to bootstrap-msi.log.
|
||||
# ASCII only - no special characters in this file.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
|
||||
$logDir = Join-Path $baseDir 'Logs'
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir 'bootstrap-msi.log'
|
||||
|
||||
function Write-Log([string]$message) {
|
||||
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
|
||||
|
||||
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
|
||||
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
|
||||
break
|
||||
} catch {
|
||||
Write-Log "Download attempt $attempt failed: $_"
|
||||
if ($attempt -eq 3) { throw }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
|
||||
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
|
||||
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<!--
|
||||
Autopilot Monitor Bootstrap MSI - thin MDM LOB delivery vehicle for WDP (Autopilot v2).
|
||||
|
||||
LOB MSIs are installed by the OMA-DM stack (EnterpriseDesktopAppManagement CSP) during
|
||||
the first MDM sync, i.e. BEFORE/parallel to the Device Preparation SLDM app batch -
|
||||
earlier than any IME-delivered script (Batch 2) or Win32 app can run. The MSI itself
|
||||
carries no bootstrap logic: it installs one runner script and executes it once. The
|
||||
runner downloads the CURRENT server-hosted Install-AutopilotMonitor.ps1, keeping all
|
||||
real logic updatable without re-shipping the MSI.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.0.2"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
|
||||
<Directory Id="INSTALLFOLDER" Name="Msi" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<Component Id="RunnerScript" Guid="8c8e8962-719f-458b-b291-dd99d3f1dc30">
|
||||
<File Id="RunnerPs1" Source="Invoke-BootstrapDownload.ps1" KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<Feature Id="Main" Level="1">
|
||||
<ComponentGroupRef Id="MainComponents" />
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the runner hits a transient error (the runner is
|
||||
additionally fail-soft itself); diagnostics live in bootstrap-msi.log. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Invoke-BootstrapDownload.ps1""
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="RunBootstrap" After="InstallFiles" Condition="NOT Installed" />
|
||||
</InstallExecuteSequence>
|
||||
</Package>
|
||||
</Wix>
|
||||
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Device Preparation (WDP) engine semantics — afee7ae0 audit (2026-08-18).
|
||||
/// WDP has no ESP, so the ESP-derived arms/gates/deadlines can never fire there.
|
||||
/// Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Profile seeding: the deterministic WDP marker seeds High confidence; the
|
||||
/// CloudAssigned* fallback rules stay Medium.</item>
|
||||
/// <item><c>completion_waiting</c> never lists <c>account_setup_provisioning_complete</c>
|
||||
/// on WDP (unsatisfiable by construction — no ESP categories).</item>
|
||||
/// <item>Desktop-first arming of the <see cref="DeadlineNames.DevicePrepCompletion"/>
|
||||
/// backstop, its fire semantics (synthetic Hello timeout + Finalizing), and the
|
||||
/// no-rearm/no-classic-leak guards.</item>
|
||||
/// <item>Arm D: the Hello-disabled fast-path completes at desktop arrival on WDP.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DevicePreparationEngineTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static List<DecisionEffect> CompletionWaitingEffects(DecisionStep step) =>
|
||||
step.Effects.Where(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "completion_waiting").ToList();
|
||||
|
||||
/// <summary>Session seeded as WDP via the EnrollmentFactsObserved signal rail.</summary>
|
||||
private static DecisionState SeedWdpSession(DecisionEngine engine, bool deterministic = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-wdp", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = deterministic ? "true" : "false",
|
||||
})).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
// ===================================================== profile seeding
|
||||
|
||||
[Fact]
|
||||
public void DeterministicV2Seed_SetsDevicePreparationAtHighConfidence()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: true);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackV2Seed_StaysAtMediumConfidence()
|
||||
{
|
||||
// CloudAssigned* fallback rules (no deterministic marker) — Medium, as before
|
||||
// d0ab3eee: an ESP-less classic profile could in principle trip them.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: false);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyV2Payload_WithoutDeterministicKey_StaysAtMediumConfidence()
|
||||
{
|
||||
// Older agents post enrollmentType=v2 without the new key — must behave like
|
||||
// the fallback (Medium), not throw or over-promote.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-legacy", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EnrollmentType] = "v2" })).NewState;
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
// ===================================================== desktop-first backstop
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_ArmsDevicePrepCompletionBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
// Hello policy unknown → no fast-path; stage parked, backstop armed.
|
||||
var backstop = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Equal(T0.AddMinutes(9).AddMinutes(30), backstop.DueAtUtc);
|
||||
Assert.Contains(step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_WaitingListsOnlyHello_NeverTheEspProvisioningGate()
|
||||
{
|
||||
// account_setup_provisioning_complete is unsatisfiable on WDP (no ESP categories)
|
||||
// and must not appear as a missing prerequisite (afee7ae0 listed it).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(step));
|
||||
Assert.Equal("hello_resolution", waiting.Parameters!["missingPrerequisites"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDesktopSignal_DoesNotRearmTheBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var second = engine.Reduce(state, MakeSignal(11, DecisionSignalKind.DesktopArrived, T0.AddMinutes(10)));
|
||||
|
||||
Assert.Single(second.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.DoesNotContain(second.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicDesktopFirst_DoesNotArmTheBackstop()
|
||||
{
|
||||
// Classic regression guard: a non-WDP session parked desktop-first keeps the
|
||||
// pre-change deadline picture (nothing armed by this handler).
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-classic", "tenant-classic", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
// ===================================================== backstop fire semantics
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutHello_SynthesizesTimeoutAndCompletesThroughFinalizing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Contains(fired.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||
|
||||
// FinalizingGrace completes the session — the full WDP no-Hello resolution chain.
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
DeadlineFired(21, T0.AddMinutes(39).AddSeconds(5), DeadlineNames.FinalizingGrace));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutDesktop_ParksInAwaitingDesktopUntilDesktopCompletes()
|
||||
{
|
||||
// The arming site (HandleDesktopArrivedV1) guarantees desktop-first, so this
|
||||
// fires only on a stale/replayed timer — Dispatch routes DeadlineFired purely on
|
||||
// the payload name, no armed-state check. The fallback must keep the handler
|
||||
// total: synthetic Hello timeout recorded, session parks in AwaitingDesktop, and
|
||||
// a later real desktop arrival still completes it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingDesktop, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
|
||||
// Hello is satisfied (synthetically) — only the desktop is still missing.
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(fired));
|
||||
Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]);
|
||||
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
MakeSignal(21, DecisionSignalKind.DesktopArrived, T0.AddMinutes(45)));
|
||||
Assert.Equal(SessionStage.Finalizing, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var resolved = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.HelloResolved, T0.AddMinutes(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
|
||||
Assert.Equal("completed", resolved.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
// ===================================================== arm D fast-path
|
||||
|
||||
[Fact]
|
||||
public void HelloDisabledFastPath_OnWdp_CompletesAtDesktopArrival()
|
||||
{
|
||||
// Arm D: no ESP facts exist on WDP, yet a disabled Hello policy + real-user
|
||||
// desktop is a legitimate completion — previously arms A–C all failed and the
|
||||
// session parked despite Hello being disabled.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user