Compare commits

..

9 Commits

Author SHA1 Message Date
Oliver Kieselbach 41898d745a updated commands 2026-08-18 01:33:44 +02:00
Oliver Kieselbach c05ea4d63b Registry second pillar: IME app state from HKLM with log reconciliation
P2 of the 2026-08-17 IME code audit. IME log wording is an unstable
contract (audit found 9 silently dead patterns, reworded lines, quote
drift, [DO TEL] removal); the registry is IME's internal product
contract - the ESP page and Intune reporting consume it.

ImeRegistryAppStateObserver + ImeRegistryAppStateHost (always-on
observability, no DecisionEngine coupling):
- one recursive RegistryWatcher on HKLM\...\IntuneManagementExtension
  covers Win32Apps (EnforcementStateMessage JSON, ExitCode, Intent),
  EspTrackingWin32Apps and SideCarPolicies\StatusServiceReports;
  2 s debounced snapshot-and-diff + 60 s periodic fallback tick
  (poll-only degraded mode if the watcher fails to arm)
- silent startup baseline: Win32Apps keys survive re-enrollments,
  pre-existing state never replays as fresh events
- registry_app_state (Info, state-change-only, 200/session cap with
  one-shot cap notice)
- app_state_reconciliation (Warning, once per app, immediate upload):
  a changed entry terminal for >= 90 s is compared against the
  log-derived AppPackageState - divergence is the built-in
  log-pattern drift alarm (registry = truth, logs = narrative)

Event types dual-registered (Constants + MCP resource-catalog + shared
manifests regenerated). OKF concept doc agent/registry-app-state-pillar.md
incl. WDP Paket-3 reuse notes. Tests: pure parse/diff/divergence logic +
tick flow via snapshot override (baseline silence, emission, dwell,
fire-once, cap). All suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:25:54 +02:00
Oliver Kieselbach 68eb2da283 IME tracker: tail AppActionProcessor.log; GRS + token-failure signals
P0b/P1 of the 2026-08-17 IME code audit (agent release required):

- Tail AppActionProcessor.log (+ rollover glob) in ImeLogTracker and the
  --run-ime-matching CLI. IME routes every [Win32App][ActionProcessor]
  line there (enforcement decision engine), so IME-NO-ACTION and
  IME-NOT-TARGETED-1/2 could never fire before, and GRS skips were
  invisible. Live session 0fe2c562 carried 16 no-action and 64
  classification lines in that file.

- New patterns (wordings verified in decompiled IME 1.97 AND 1.104):
  IME-GRS-SKIP ("is in GRS. The app will not be enforced." -> Postponed;
  THE canonical silent non-install reason), IME-GRS-CLEARED (early
  subgraph termination, retry next check-in -> Postponed),
  IME-TOKEN-FAILURE ("Failed to get AAD token ... errorCode = N").

- New action imeTokenFailure -> ime_token_failure Warning event (new
  EventType: Constants + MCP resource-catalog + shared manifests
  regenerated). Tracker dedups per distinct errorCode; included in
  the historic-replay guard so stale lines from a previous enrollment
  cannot emit a misleading warning. Complements IME-TOKEN-SUCCESS:
  an auth outage previously left check-ins silently doing nothing.

Tests: adapter emission + unknown-code + tracker dedup; known-actions
catalog extended; all suites green (Functions 4262, Agent 2252, web 762).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:01:02 +02:00
Oliver Kieselbach ab0f27b5ab Download times: event-window fallback for IME 1.104 (DO TEL removed)
IME 1.104 no longer logs the [DO TEL] line whose JSON carried the
DO-reported download duration - the Download Progress card lost all
times (per-app and total).

Durations now fall back to the observed event window (first download
event -> first completion-marking event) whenever the DO-reported value
is absent, and completed items show "in <duration>" inline. Works
retroactively for already-stored sessions, no agent release required;
the DO-reported value still wins when present (older IME versions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:40:53 +02:00
Oliver Kieselbach 32d8151185 updated .gitignore 2026-08-18 00:28:29 +02:00
Oliver Kieselbach f3749ccc16 updated commands 2026-08-18 00:24:17 +02:00
Oliver Kieselbach ca26cb4f7c backend: auto-archive IME installer on first sighting of a new version
When RecordImeVersionAsync reports a genuinely new IME version (~monthly),
EventIngestProcessor now also enqueues an ime-msi-archive job. The worker
(QueuePollingWorker, heartbeat, pause on ImeMsiArchivingEnabled=false)
downloads the installer and stores it write-once (If-None-Match:*) in the
permanent ime-archive container ({version}/IntuneWindowsAgent.msi +
provenance.json), then merges MsiArchiveStatus/BlobPath/Sha256/Bytes/SourceUrl
onto the ImeVersionHistory row - so every fleet-observed IME build stays
available for decompilation/diffing after Microsoft's versionless CDN moves on.

The download URL comes from agent telemetry (msiDownloadUrl enrichment) and is
treated as untrusted: https-only *.manage.microsoft.com allowlist with exact
filename match, canonical versionless fallback, no redirects, size cap
(MaxImeMsiDownloadSizeMB, default 250, preflight + mid-stream) enforced by a
SHA-256-hashing pass-through stream. Archiver never throws; the worker retries
only transient statuses (download/timeout/error), permanent rejections
complete with their Failed:* status persisted.

Admin settings ImeMsiArchivingEnabled (default true) + MaxImeMsiDownloadSizeMB
via the full 4-file web chain; archive fields on the version-history API are
GA-only (non-GA projection unchanged, redaction tests extended). Shared
manifests regenerated. Tests: archiver status branches via recording subclass
(allowlist, caps, 409 idempotency, cancellation propagation), config defaults.
Backend suite 4262 green, web tsc + vitest 762 green, solution 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:20:53 +02:00
Oliver Kieselbach 8ceab7a064 updated commands 2026-08-17 23:51:56 +02:00
Oliver Kieselbach ae85dc325c gitignore: exclude local ime-files/ working directory
Machine-local reference files, kept out of version control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 23:51:08 +02:00
46 changed files with 2583 additions and 14 deletions
+1
View File
@@ -158,3 +158,4 @@ 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
+89
View File
@@ -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`
+1
View File
@@ -25,6 +25,7 @@ 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.
* [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
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-18
* **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.
+24
View File
@@ -358,6 +358,22 @@
"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",
@@ -608,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). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
},
{
"patternId": "IME-TOKEN-SUCCESS",
"category": "always",
@@ -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
}
+9
View File
@@ -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
}
@@ -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). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
}
+1
View File
@@ -61,6 +61,7 @@
"cancelStuckAndSetCurrent",
"imeSessionChange",
"imeImpersonation",
"imeTokenFailure",
"enrollmentCompleted",
"updateDoTelemetry",
"scriptStarted",
@@ -0,0 +1,264 @@
#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));
[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 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 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();
}
}
@@ -462,6 +462,48 @@ 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_tracker_dedups_per_error_code()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
f.Tracker.HandleImeTokenFailureForTest("100", "line b"); // same code — suppressed
f.Tracker.HandleImeTokenFailureForTest("200", "line c"); // new code — emitted
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeTokenFailure).Count);
}
[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()
{
@@ -25,6 +25,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
// IME retries token acquisition on every check-in — dedup per distinct error
// code so an outage yields one Warning, not a timeline flood.
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private void HandleImeTokenFailure(string errorCode, string message)
{
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
if (!_reportedTokenFailureCodes.Add(code)) return;
var excerpt = message ?? string.Empty;
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
_logger.Warning($"ImeLogTracker: IME token acquisition failure (errorCode={code})");
OnImeTokenFailure?.Invoke(code, excerpt);
}
// Test seam: drives HandleImeTokenFailure without the regex pipeline.
internal void HandleImeTokenFailureForTest(string errorCode, string message)
=> HandleImeTokenFailure(errorCode, message);
private void HandleImeImpersonation(string user)
{
var current = string.IsNullOrEmpty(user) ? "(unknown)" : user;
@@ -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)
@@ -296,6 +299,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
@@ -0,0 +1,577 @@
#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\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — EnforcementStateMessage JSON
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
/// <item><c>EspTrackingWin32Apps\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — which apps IME
/// registered for ESP tracking and in which phase.</item>
/// <item><c>SideCarPolicies\StatusServiceReports\&lt;userGuid&gt;\&lt;appId&gt;</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 — see class doc.
_last = snapshot;
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
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"));
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>&lt;appGuid&gt;_&lt;revision&gt;</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);
}
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 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; }
}
}
@@ -326,6 +326,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
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,
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:
// build jump proved an OOBE quality update the WU channel never showed). Must be added
@@ -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();
}
}
@@ -76,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;
@@ -86,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;
@@ -175,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.
@@ -186,6 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
_ourOnScriptCompleted = OnScriptCompleted;
_ourOnScriptStarted = OnScriptStarted;
_ourOnImeTokenFailure = OnImeTokenFailure;
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
@@ -195,6 +199,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
_tracker.OnScriptStarted = _ourOnScriptStarted;
_tracker.OnImeTokenFailure = _ourOnImeTokenFailure;
}
public void Dispose()
@@ -217,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)
@@ -267,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) =>
@@ -277,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
@@ -899,6 +913,34 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
: $"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;
@@ -30,6 +30,8 @@ namespace AutopilotMonitor.Agent.V2
"IntuneManagementExtension-????????-??????.log",
"AppWorkload.log",
"AppWorkload-????????-??????.log",
"AppActionProcessor.log",
"AppActionProcessor-????????-??????.log",
"AgentExecutor.log",
"AgentExecutor-????????-??????.log",
"HealthScripts.log",
@@ -0,0 +1,44 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value tests for the IME-installer archiving settings. The flag gates the
/// ime-msi-archive queue worker (pause, not drop); the size cap guards the download.
/// Defaulting the flag to TRUE is deliberate: archiving is operator infrastructure with
/// ~one small download per Microsoft IME release, and a version missed while the flag is
/// off is gone for good (the versionless CDN moves on).
/// </summary>
public class AdminConfigurationImeMsiArchivingTests
{
[Fact]
public void ImeMsiArchivingEnabled_defaults_to_true_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.True(cfg.ImeMsiArchivingEnabled);
}
[Fact]
public void ImeMsiArchivingEnabled_defaults_to_true_on_CreateDefault()
{
var cfg = AdminConfiguration.CreateDefault();
Assert.True(cfg.ImeMsiArchivingEnabled);
}
[Fact]
public void MaxImeMsiDownloadSizeMB_defaults_to_250()
{
// 250 MB: ~20x today's MSI so a legitimately grown installer is never missed,
// while a tampered URL still cannot stream gigabytes (Oliver, 2026-08-18).
Assert.Equal(250, new AdminConfiguration().MaxImeMsiDownloadSizeMB);
Assert.Equal(250, AdminConfiguration.CreateDefault().MaxImeMsiDownloadSizeMB);
}
[Fact]
public void Settings_persist_on_the_config()
{
var cfg = new AdminConfiguration { ImeMsiArchivingEnabled = false, MaxImeMsiDownloadSizeMB = 50 };
Assert.False(cfg.ImeMsiArchivingEnabled);
Assert.Equal(50, cfg.MaxImeMsiDownloadSizeMB);
}
}
@@ -171,7 +171,7 @@ public class BuiltInRulesTests
"updateStatePostponed", "espPhaseDetected", "imeStarted",
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
"espTrackStatus", "updateName", "updateWin32AppState",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"resetPlatformScriptContext",
@@ -39,6 +39,26 @@ public class GetImeVersionHistoryRedactionTests
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The MSI-archive columns (ImeMsiArchiver) are operator infrastructure — blob paths,
/// hashes and source URLs of the platform's own archive have no business in a tenant
/// member's payload. Pinned like the identifiers so the boundary survives refactors.
/// </summary>
[Fact]
public void NonGlobalCaller_DropsMsiArchiveFields()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("msiArchiveStatus", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiArchiveBlobPath", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiSha256", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiBytes", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiSourceUrl", json, StringComparison.OrdinalIgnoreCase);
// Values too, not just keys (same rationale as the identifier-value test below).
Assert.DoesNotContain("4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("imeswdb-afd-secondary", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The values, not just the property names: a rename of the model property must not let the
/// identifier survive under a different key. Asserting on the GUIDs catches that.
@@ -101,7 +121,12 @@ public class GetImeVersionHistoryRedactionTests
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
new[]
{
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
"msiArchiveBlobPath", "msiArchiveStatus", "msiBytes", "msiSha256", "msiSourceUrl",
"sessionCount", "version",
},
keys);
}
@@ -185,5 +210,10 @@ public class GetImeVersionHistoryRedactionTests
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
LastSeenAt = Last,
SessionCount = 3109,
MsiArchiveStatus = "Archived",
MsiArchiveBlobPath = $"{version}/IntuneWindowsAgent.msi",
MsiSha256 = "4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e",
MsiBytes = 13_107_200,
MsiSourceUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
};
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Ime;
using AutopilotMonitor.Shared.Models;
using Azure;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The IME-installer archiver downloads a newly sighted IME build into the permanent
/// ime-archive container. Its contract: NEVER throw (worker decides retry from the result),
/// treat the agent-supplied URL as untrusted (allowlist, else canonical fallback), enforce
/// the size cap both at Content-Length preflight and mid-stream, and stay idempotent under
/// queue re-delivery (409 on the write-once upload == archived). Tests pin every status
/// branch via a recording subclass — no Azurite, no HTTP.
/// </summary>
public class ImeMsiArchiverTests
{
private const string Version = "1.104.102.0";
private const string EventUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
// =========================================================================
// URL allowlist (static — the SSRF boundary)
// =========================================================================
[Theory]
[InlineData(EventUrl, true)]
[InlineData("https://swda01-mscdn.manage.microsoft.com/IntuneWindowsAgent.msi", true)]
[InlineData("https://manage.microsoft.com/IntuneWindowsAgent.msi", true)] // apex host
[InlineData("https://x.manage.microsoft.com/path/IntuneWindowsAgent.msi?sv=1&sig=2", true)] // query ignored
[InlineData("https://x.manage.microsoft.com/intunewindowsagent.MSI", true)] // case-insensitive
[InlineData("http://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi", false)] // https only
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi", false)]
[InlineData("https://manage.microsoft.com.evil.com/IntuneWindowsAgent.msi", false)] // suffix spoof
[InlineData("https://xmanage.microsoft.com/IntuneWindowsAgent.msi", false)] // needs the dot
[InlineData("https://x.manage.microsoft.com/SomethingElse.msi", false)] // wrong filename
[InlineData("https://x.manage.microsoft.com/IntuneWindowsAgent.msi.exe", false)]
[InlineData("not a url", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsAllowedMsiUrl_cases(string? url, bool expected)
{
Assert.Equal(expected, ImeMsiArchiver.IsAllowedMsiUrl(url));
}
// =========================================================================
// Version guard (blob path is built from untrusted input)
// =========================================================================
[Theory]
[InlineData("")]
[InlineData("1.104.102.0.1")] // too many components
[InlineData("1.104../escape")]
[InlineData("latest")]
[InlineData("1.104.102.0 ")]
public async Task ArchiveAsync_BadVersion_PermanentFailure_NoDownload(string version)
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(version: version));
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedBadVersion, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.DownloadCalls + archiver.UploadCalls);
}
// =========================================================================
// URL selection
// =========================================================================
[Fact]
public async Task ArchiveAsync_AllowedEventUrl_IsUsed()
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: EventUrl));
Assert.True(result.Success);
Assert.Equal(EventUrl, archiver.DownloadLastUrl);
Assert.Equal(EventUrl, result.SourceUrl);
}
[Theory]
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi")] // allowlist reject
[InlineData(null)] // pre-enrichment agent
public async Task ArchiveAsync_MissingOrRejectedEventUrl_FallsBackToCanonical(string? url)
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: url));
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, archiver.DownloadLastUrl);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, result.SourceUrl);
}
// =========================================================================
// Happy path
// =========================================================================
[Fact]
public async Task ArchiveAsync_HappyPath_UploadsMsiAndProvenance_WithHashAndSize()
{
var payload = new byte[3 * 1024 * 1024];
new Random(42).NextBytes(payload);
var expectedSha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
var archiver = new RecordingArchiver { Payload = payload };
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
Assert.Equal(expectedSha, result.Sha256);
Assert.Equal(payload.Length, result.SizeBytes);
Assert.Equal(1, archiver.UploadCalls);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", archiver.UploadLastPath);
Assert.Equal(payload.Length, archiver.UploadedByteCount);
Assert.Equal(1, archiver.ProvenanceCalls);
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
Assert.Contains(Version, archiver.ProvenanceLastJson);
}
// =========================================================================
// Size cap — preflight and mid-stream
// =========================================================================
[Fact]
public async Task ArchiveAsync_ContentLengthAboveCap_Rejected_NoUpload()
{
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 1,
Payload = new byte[2 * 1024 * 1024],
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.UploadCalls);
}
[Fact]
public async Task ArchiveAsync_UnknownContentLength_CapStillEnforcedMidStream()
{
// A lying/absent Content-Length must not bypass the cap — the hashing wrapper
// aborts the stream as soon as the byte count crosses it.
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 1,
Payload = new byte[2 * 1024 * 1024],
ReportContentLength = false,
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.ProvenanceCalls);
}
[Fact]
public async Task ArchiveAsync_CapZero_MeansUnlimited()
{
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 0,
Payload = new byte[2 * 1024 * 1024],
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
}
// =========================================================================
// Idempotency under queue re-delivery
// =========================================================================
[Fact]
public async Task ArchiveAsync_BlobAlreadyExists_TreatedAsArchived_NotRetryable()
{
var archiver = new RecordingArchiver
{
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
Assert.False(result.Retryable);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
}
// =========================================================================
// Transient failures — retryable, never thrown
// =========================================================================
[Fact]
public async Task ArchiveAsync_DownloadFails_RetryableDownloadStatus()
{
var archiver = new RecordingArchiver
{
DownloadException = new HttpRequestException("boom"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedDownload, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_Timeout_RetryableTimeoutStatus()
{
var archiver = new RecordingArchiver
{
DownloadException = new OperationCanceledException(),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTimeout, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_UnexpectedUploadError_RetryableErrorStatus()
{
var archiver = new RecordingArchiver
{
UploadException = new InvalidOperationException("storage hiccup"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_CallerCancellation_Propagates()
{
// Host shutdown must reach the worker's dispatch loop so the message stays queued.
var archiver = new RecordingArchiver();
using var cts = new CancellationTokenSource();
cts.Cancel();
archiver.DownloadException = new OperationCanceledException(cts.Token);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => archiver.ArchiveAsync(Envelope(), cts.Token));
}
// =========================================================================
// Helpers
// =========================================================================
private static ImeMsiArchiveEnvelope Envelope(string version = Version, string? url = EventUrl) =>
new()
{
Version = version,
MsiDownloadUrl = url,
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
EnqueuedAt = DateTime.UtcNow,
};
private sealed class RecordingArchiver : ImeMsiArchiver
{
public byte[] Payload { get; set; } = new byte[2048];
public bool ReportContentLength { get; set; } = true;
public int MaxDownloadSizeMB { get; set; } = 250;
public Exception? DownloadException { get; set; }
public Exception? UploadException { get; set; }
public int DownloadCalls { get; private set; }
public string? DownloadLastUrl { get; private set; }
public int UploadCalls { get; private set; }
public string? UploadLastPath { get; private set; }
public long UploadedByteCount { get; private set; }
public int ProvenanceCalls { get; private set; }
public string? ProvenanceLastPath { get; private set; }
public string ProvenanceLastJson { get; private set; } = string.Empty;
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration { MaxImeMsiDownloadSizeMB = MaxDownloadSizeMB });
protected override Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
string url, CancellationToken cancellationToken)
{
DownloadCalls++;
DownloadLastUrl = url;
if (DownloadException is not null) throw DownloadException;
return Task.FromResult(
((Stream)new MemoryStream(Payload), ReportContentLength ? Payload.LongLength : -1L));
}
protected override async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastPath = blobPath;
// Drain the stream like the real blob upload would — this is what drives the
// hashing/cap wrapper (and mid-stream aborts surface here as exceptions).
var buffer = new byte[64 * 1024];
int read;
while ((read = await content.ReadAsync(buffer, cancellationToken)) > 0)
UploadedByteCount += read;
if (UploadException is not null) throw UploadException;
}
protected override Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
{
ProvenanceCalls++;
ProvenanceLastPath = blobPath;
ProvenanceLastJson = json;
return Task.CompletedTask;
}
}
}
@@ -818,6 +818,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
{ "ImeMsiArchivingEnabled", config.ImeMsiArchivingEnabled },
{ "MaxImeMsiDownloadSizeMB", config.MaxImeMsiDownloadSizeMB },
{ "SelfServiceAppHomingEnabled", config.SelfServiceAppHomingEnabled },
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
@@ -885,6 +887,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
ImeMsiArchivingEnabled = entity.GetBoolean("ImeMsiArchivingEnabled") ?? true,
MaxImeMsiDownloadSizeMB = entity.GetInt32("MaxImeMsiDownloadSizeMB") ?? 250,
SelfServiceAppHomingEnabled = entity.GetBoolean("SelfServiceAppHomingEnabled") ?? false,
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
@@ -172,6 +172,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync()
=> _storage.GetImeVersionHistoryAsync();
public Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
=> _storage.UpdateImeVersionArchiveInfoAsync(version, status, blobPath, sha256, sizeBytes, sourceUrl);
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
{
var result = await _storage.StoreEventAsync(evt);
@@ -334,6 +334,26 @@ builder.Services.AddSingleton<
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateQueueWorker>();
// IME-installer archiving on first fleet-wide sighting of a new IME version (~monthly).
// Producer sits in EventIngestProcessor's new-version continuation; the worker downloads
// the MSI from the CSP-reported (allowlisted) URL into the permanent ime-archive container
// and merges the outcome onto the ImeVersionHistory row. Typed HttpClient: no redirects
// (SSRF posture — the allowlisted host must serve directly), long timeout owned by the
// archiver's own CTS, transient retry via the shared external-data policy.
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiver>()
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
})
.ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan)
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Ime.IImeMsiArchiveProducer,
AutopilotMonitor.Functions.Services.Ime.AzureQueueImeMsiArchiveProducer>();
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiveQueueWorker>();
// Delayed tenant auto-approve (public availability). Producer fires at first-login signup
// with a ~1-minute visibility delay; the worker activates the tenant via the shared
// TenantApprovalService only if AdminConfiguration.AutoApproveNewTenants is enabled at
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer _analyzeProducer;
private readonly AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry _interimTriggerRegistry;
private readonly IVulnerabilityCorrelateProducer _vulnProducer;
private readonly Ime.IImeMsiArchiveProducer _imeMsiArchiveProducer;
public EventIngestProcessor(
ILogger<EventIngestProcessor> logger,
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Services
TelemetryClient telemetryClient,
AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer analyzeProducer,
AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry interimTriggerRegistry,
IVulnerabilityCorrelateProducer vulnProducer)
IVulnerabilityCorrelateProducer vulnProducer,
Ime.IImeMsiArchiveProducer imeMsiArchiveProducer)
{
_logger = logger;
_sessionRepo = sessionRepo;
@@ -74,6 +76,7 @@ namespace AutopilotMonitor.Functions.Services
_analyzeProducer = analyzeProducer;
_interimTriggerRegistry = interimTriggerRegistry;
_vulnProducer = vulnProducer;
_imeMsiArchiveProducer = imeMsiArchiveProducer;
}
/// <summary>
@@ -129,6 +132,14 @@ namespace AutopilotMonitor.Functions.Services
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted);
// Captured here (not inside the continuation) purely for clarity — the agent's
// CSP-registry enrichment; absent on agent builds before 2026-08-17.
string? msiDownloadUrl = null, msiMatchedBy = null;
if (imeVersionEvent.Data.TryGetValue("msiDownloadUrl", out var urlObj))
msiDownloadUrl = urlObj?.ToString();
if (imeVersionEvent.Data.TryGetValue("msiMatchedBy", out var matchedByObj))
msiMatchedBy = matchedByObj?.ToString();
_ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId)
.ContinueWith(async t =>
{
@@ -141,6 +152,19 @@ namespace AutopilotMonitor.Functions.Services
{
await _opsEventService.RecordNewImeVersionDetectedAsync(
imeVersion, request.TenantId, request.SessionId);
// First fleet-wide sighting → archive the installer binary while
// Microsoft's versionless CDN still serves exactly this build.
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
await _imeMsiArchiveProducer.EnqueueAsync(new ImeMsiArchiveEnvelope
{
Version = imeVersion,
MsiDownloadUrl = msiDownloadUrl,
MsiMatchedBy = msiMatchedBy,
TenantId = request.TenantId,
SessionId = request.SessionId,
EnqueuedAt = DateTime.UtcNow,
});
}
}, TaskScheduler.Default);
}
@@ -0,0 +1,84 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Storage.Queues;
using AutopilotMonitor.Functions.Services.Queueing;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Azure Storage Queue implementation of <see cref="IImeMsiArchiveProducer"/>. Mirrors
/// <see cref="Analyze.AzureQueueAnalyzeOnEnrollmentEndProducer"/>: Managed Identity
/// preferred, connection-string fallback, Base64 encoding (matches the consumer worker).
/// <para>
/// <b>Fault tolerance:</b> queue-side exceptions are logged but never rethrown — the
/// caller is the ingest path's fire-and-forget continuation. A missed enqueue means the
/// version's installer is not auto-archived; the /ime-decompile skill's blob backfill
/// is the manual fallback.
/// </para>
/// </summary>
public sealed class AzureQueueImeMsiArchiveProducer : IImeMsiArchiveProducer
{
private readonly QueueClient _queueClient;
private readonly ILogger<AzureQueueImeMsiArchiveProducer> _logger;
private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run
public AzureQueueImeMsiArchiveProducer(
QueueClientFactory queueFactory,
ILogger<AzureQueueImeMsiArchiveProducer> logger)
{
_logger = logger;
// Base64 encoding matches the consumer (BackgroundService) so messages round-trip.
_queueClient = queueFactory.Create(Constants.QueueNames.ImeMsiArchive);
}
public async Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
if (envelope is null) return;
if (string.IsNullOrEmpty(envelope.Version))
{
_logger.LogWarning("ImeMsiArchive enqueue skipped — missing Version");
return;
}
await EnsureQueueExistsAsync(cancellationToken).ConfigureAwait(false);
try
{
var body = JsonConvert.SerializeObject(envelope);
await _queueClient.SendMessageAsync(body, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"ImeMsiArchive enqueued (version={Version} urlPresent={UrlPresent} tenant={Tenant} session={Session})",
envelope.Version, !string.IsNullOrEmpty(envelope.MsiDownloadUrl), envelope.TenantId, envelope.SessionId);
}
catch (Exception ex)
{
// Fail-soft — ingest continuation. Skill-side blob backfill is the fallback.
_logger.LogWarning(ex,
"ImeMsiArchive enqueue failed (version={Version}) — /ime-decompile blob backfill is the fallback",
envelope.Version);
}
}
private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken)
{
if (_queueEnsured == 1) return;
try
{
await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
Interlocked.Exchange(ref _queueEnsured, 1);
}
catch (Exception ex)
{
// Leave _queueEnsured=0 so a later call retries.
_logger.LogWarning(ex, "ImeMsiArchive queue CreateIfNotExists failed — will retry next enqueue");
}
}
}
}
@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Enqueues an IME-installer archive job when a new IME version is first sighted.
/// Implementations must be fail-soft: the caller sits on the ingest hot path's
/// fire-and-forget continuation and must never observe an exception.
/// </summary>
public interface IImeMsiArchiveProducer
{
Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,87 @@
using AutopilotMonitor.Functions.Services.Queueing;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Background worker for the <c>ime-msi-archive</c> queue: downloads a newly sighted IME
/// installer into the permanent <c>ime-archive</c> container and merges the outcome onto
/// the ImeVersionHistory row. Volume is ~one message per Microsoft IME release.
/// <para>
/// Retry semantics: the archiver never throws — it classifies. Permanent rejections
/// (bad version, size cap) complete the message with their <c>Failed:*</c> status
/// persisted; transient failures (download/timeout) rethrow so the visibility-timeout
/// retry → poison ladder applies, with the last status visible on the row.
/// <see cref="UseHeartbeat"/> keeps the message invisible across the up-to-10-minute
/// download window. The status row-merge happens on EVERY attempt so operators can see
/// a stuck download as <c>Failed:Download</c> instead of nothing.
/// </para>
/// </summary>
public sealed class ImeMsiArchiveQueueWorker : QueuePollingWorker<ImeMsiArchiveEnvelope>
{
private readonly ImeMsiArchiver _archiver;
private readonly ISessionRepository _sessionRepo;
private readonly AdminConfigurationService _adminConfigService;
public ImeMsiArchiveQueueWorker(
QueueClientFactory queueFactory,
ImeMsiArchiver archiver,
ISessionRepository sessionRepo,
AdminConfigurationService adminConfigService,
ILogger<ImeMsiArchiveQueueWorker> logger)
: base(queueFactory, Constants.QueueNames.ImeMsiArchive, logger)
{
_archiver = archiver ?? throw new ArgumentNullException(nameof(archiver));
_sessionRepo = sessionRepo ?? throw new ArgumentNullException(nameof(sessionRepo));
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
}
/// <summary>Download can legitimately take minutes — extend visibility while working.</summary>
protected override bool UseHeartbeat => true;
/// <summary>
/// Paused while <see cref="AdminConfiguration.ImeMsiArchivingEnabled"/> is off —
/// messages stay parked (not dropped) and are processed when the flag returns.
/// Uses the cached config read: this is a pause, not a kill-switch, and the worker
/// only wakes for rare messages anyway.
/// </summary>
protected override async ValueTask<bool> ShouldPauseAsync(CancellationToken ct)
{
try
{
var config = await _adminConfigService.GetConfigurationAsync();
return !config.ImeMsiArchivingEnabled;
}
catch
{
return false; // config unreadable → keep working (archive is fail-soft anyway)
}
}
protected override bool TryValidate(ImeMsiArchiveEnvelope envelope)
=> !string.IsNullOrEmpty(envelope.Version);
protected override string DescribeForLog(ImeMsiArchiveEnvelope envelope)
=> $"version {envelope.Version}";
protected override async Task HandleAsync(ImeMsiArchiveEnvelope envelope, CancellationToken ct)
{
var result = await _archiver.ArchiveAsync(envelope, ct);
// Merge the outcome first — also for failures, so the row always tells the truth.
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
envelope.Version, result.Status, result.BlobPath, result.Sha256, result.SizeBytes, result.SourceUrl);
if (!result.Success && result.Retryable)
{
// Visibility-timeout retry → poison after MaxDequeueCount. The thrown message
// lands in the worker's handler-failure log line.
throw new InvalidOperationException(
$"IME MSI archive attempt failed with {result.Status} for version {envelope.Version}");
}
}
}
}
@@ -0,0 +1,375 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Outcome of an IME-installer archive attempt. <see cref="Status"/> is one of the
/// <see cref="ImeMsiArchiver.Statuses"/> constants and is merged verbatim onto the
/// ImeVersionHistory row. <see cref="Retryable"/> tells the queue worker whether to
/// trigger a visibility-timeout retry (transient download problems) or complete the
/// message (permanent rejections).
/// </summary>
public sealed record ImeMsiArchiveResult(
bool Success, string Status, bool Retryable,
string? BlobPath, string? Sha256, long? SizeBytes, string? SourceUrl);
/// <summary>
/// Downloads a newly sighted IME installer and archives it into the permanent
/// <see cref="Constants.BlobContainers.ImeArchive"/> container
/// (<c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>).
/// Modeled on <see cref="Diagnostics.SessionReportDiagnosticsArchiveCopier"/>:
/// fail-soft by contract (never throws), Statuses constants persisted verbatim,
/// admin-configured size cap, typed catch ladder, <c>protected virtual</c> test seams.
/// <para>
/// The download URL comes from agent telemetry and is therefore untrusted: only HTTPS
/// URLs on <c>*.manage.microsoft.com</c> whose path filename is exactly
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
/// can never overwrite an existing archive entry.
/// </para>
/// </summary>
public class ImeMsiArchiver
{
/// <summary>Persisted MsiArchiveStatus values.</summary>
public static class Statuses
{
public const string Archived = "Archived";
public const string FailedBadVersion = "Failed:BadVersion";
public const string FailedTooLarge = "Failed:TooLarge";
public const string FailedDownload = "Failed:Download";
public const string FailedTimeout = "Failed:Timeout";
public const string FailedError = "Failed:Error";
}
/// <summary>
/// The CSP's versionless distribution endpoint — always serves the currently rolled-out
/// build. Fallback when the event carried no (acceptable) URL.
/// </summary>
public const string CanonicalMsiUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
private const string MsiFileName = "IntuneWindowsAgent.msi";
private const string AllowedHostSuffix = ".manage.microsoft.com";
/// <summary>Hard ceiling for one download attempt; the worker's heartbeat covers it.</summary>
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
internal static readonly Regex VersionRegex = new(@"^\d+(\.\d+){1,3}$", RegexOptions.Compiled);
private readonly HttpClient _httpClient;
private readonly BlobStorageService _blobStorage;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<ImeMsiArchiver> _logger;
public ImeMsiArchiver(
HttpClient httpClient,
BlobStorageService blobStorage,
AdminConfigurationService adminConfigService,
ILogger<ImeMsiArchiver> logger)
{
_httpClient = httpClient;
_blobStorage = blobStorage;
_adminConfigService = adminConfigService;
_logger = logger;
}
/// <summary>
/// Test seam: Azure-free construction for recording subclasses that override the
/// virtual download/upload/config seams below.
/// </summary>
protected ImeMsiArchiver()
{
_httpClient = null!;
_blobStorage = null!;
_adminConfigService = null!;
_logger = NullLogger<ImeMsiArchiver>.Instance;
}
/// <summary>
/// True when <paramref name="url"/> is an HTTPS URL on <c>*.manage.microsoft.com</c>
/// whose path filename is exactly <c>IntuneWindowsAgent.msi</c> (query ignored).
/// </summary>
public static bool IsAllowedMsiUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttps) return false;
var host = uri.Host;
var hostAllowed =
host.EndsWith(AllowedHostSuffix, StringComparison.OrdinalIgnoreCase) ||
host.Equals(AllowedHostSuffix.TrimStart('.'), StringComparison.OrdinalIgnoreCase);
if (!hostAllowed) return false;
var fileName = uri.Segments.Length > 0 ? uri.Segments[^1] : string.Empty;
return fileName.Equals(MsiFileName, StringComparison.OrdinalIgnoreCase);
}
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
if (envelope is null || !VersionRegex.IsMatch(envelope.Version ?? string.Empty))
{
_logger.LogWarning(
"ImeMsiArchiver: rejecting version {Version} — not a plain dotted version string",
envelope?.Version ?? "(null)");
return new ImeMsiArchiveResult(false, Statuses.FailedBadVersion, Retryable: false,
null, null, null, null);
}
var version = envelope.Version!;
var blobPath = $"{version}/{MsiFileName}";
string sourceUrl;
if (IsAllowedMsiUrl(envelope.MsiDownloadUrl))
{
sourceUrl = envelope.MsiDownloadUrl!;
}
else
{
if (!string.IsNullOrEmpty(envelope.MsiDownloadUrl))
{
_logger.LogWarning(
"ImeMsiArchiver: event URL rejected by allowlist for version {Version} — using canonical URL",
version);
}
sourceUrl = CanonicalMsiUrl;
}
try
{
var adminConfig = await GetAdminConfigurationAsync();
var maxSizeBytes = (long)adminConfig.MaxImeMsiDownloadSizeMB * 1024 * 1024;
using var cts = new CancellationTokenSource(DownloadTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
var (content, contentLength) = await OpenDownloadAsync(sourceUrl, linked.Token);
string sha256;
long totalBytes;
using (content)
{
// Same fast-reject as the diagnostics proxy: cap of 0 means unlimited.
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} rejected — Content-Length {SizeBytes} exceeds limit {MaxSizeBytes}",
version, contentLength, maxSizeBytes);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, contentLength, sourceUrl);
}
// Hash + count while streaming into the blob; the wrapper enforces the cap
// mid-stream too (a lying/absent Content-Length cannot bypass it).
using var guarded = new HashingCappedReadStream(content, maxSizeBytes);
try
{
await UploadMsiAsync(blobPath, guarded, linked.Token);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Queue re-delivery after a successful upload: the archive already has
// this version — that IS the desired end state.
_logger.LogInformation(
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
blobPath);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, null, null, sourceUrl);
}
sha256 = guarded.Sha256Hex;
totalBytes = guarded.TotalBytes;
}
var provenance = new
{
version,
url = sourceUrl,
urlFromEvent = sourceUrl != CanonicalMsiUrl,
msiMatchedBy = envelope.MsiMatchedBy,
sha256,
msiBytes = totalBytes,
firstSeenTenantId = envelope.TenantId,
firstSeenSessionId = envelope.SessionId,
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
await UploadProvenanceAsync(
$"{version}/provenance.json",
System.Text.Json.JsonSerializer.Serialize(provenance,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
linked.Token);
_logger.LogInformation(
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
version, totalBytes, sha256, sourceUrl);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
catch (SizeCapExceededException)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} exceeded the size cap mid-stream — aborted",
version);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, null, sourceUrl);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("ImeMsiArchiver: download for {Version} timed out", version);
return new ImeMsiArchiveResult(false, Statuses.FailedTimeout, Retryable: true,
null, null, null, sourceUrl);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: download for {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedDownload, Retryable: true,
null, null, null, sourceUrl);
}
catch (OperationCanceledException)
{
// Host shutdown — let the worker's dispatch loop see it and leave the message.
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: archiving {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedError, Retryable: true,
null, null, null, sourceUrl);
}
}
// -------- Test seams (production defaults hit Azure / the network) --------
/// <summary>Fetches the platform admin configuration (size cap).</summary>
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
=> _adminConfigService.GetConfigurationAsync();
/// <summary>Opens the installer download stream (headers-read, no buffering).</summary>
protected virtual async Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
string url, CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(
url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStreamAsync(cancellationToken);
return (content, response.Content.Headers.ContentLength ?? -1);
}
/// <summary>
/// Uploads the installer stream write-once (If-None-Match:*). Implementations must
/// surface a conflict as <see cref="RequestFailedException"/> with status 409.
/// </summary>
protected virtual async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(blobPath);
await blobClient.UploadAsync(content, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/octet-stream" },
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All }
}, cancellationToken);
}
/// <summary>Uploads the provenance sidecar (overwrite allowed — it is derived data).</summary>
protected virtual async Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
var blobClient = containerClient.GetBlobClient(blobPath);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }
}, cancellationToken);
}
// -------- Streaming helpers --------
private sealed class SizeCapExceededException : Exception
{
}
/// <summary>
/// Read-only pass-through stream that SHA-256-hashes and counts everything read and
/// throws once the byte count exceeds the cap (0 = unlimited). Non-seekable on
/// purpose — the blob SDK then stages blocks instead of demanding Length upfront.
/// </summary>
private sealed class HashingCappedReadStream : Stream
{
private readonly Stream _inner;
private readonly long _maxBytes;
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
private string? _sha256Hex;
public HashingCappedReadStream(Stream inner, long maxBytes)
{
_inner = inner;
_maxBytes = maxBytes;
}
public long TotalBytes { get; private set; }
public string Sha256Hex => _sha256Hex ??=
Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => TotalBytes;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = _inner.Read(buffer, offset, count);
Account(buffer.AsSpan(offset, read));
return read;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await _inner.ReadAsync(buffer, cancellationToken);
Account(buffer.Span[..read]);
return read;
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
private void Account(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) return;
TotalBytes += data.Length;
if (_maxBytes > 0 && TotalBytes > _maxBytes)
throw new SizeCapExceededException();
_hash.AppendData(data);
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing) _hash.Dispose();
base.Dispose(disposing);
}
}
}
}
@@ -3316,7 +3316,12 @@ namespace AutopilotMonitor.Functions.Services
FirstSeenSessionId = entity.GetString("FirstSeenSessionId") ?? string.Empty,
FirstSeenTenantId = entity.GetString("FirstSeenTenantId") ?? string.Empty,
LastSeenAt = entity.GetDateTime("LastSeenAt") ?? DateTime.MinValue,
SessionCount = entity.GetInt32("SessionCount") ?? 0
SessionCount = entity.GetInt32("SessionCount") ?? 0,
MsiArchiveStatus = entity.GetString("MsiArchiveStatus"),
MsiArchiveBlobPath = entity.GetString("MsiArchiveBlobPath"),
MsiSha256 = entity.GetString("MsiSha256"),
MsiBytes = entity.GetInt64("MsiBytes"),
MsiSourceUrl = entity.GetString("MsiSourceUrl")
});
}
}
@@ -3328,6 +3333,38 @@ namespace AutopilotMonitor.Functions.Services
return results.OrderByDescending(e => e.FirstSeenAt).ToList();
}
/// <summary>
/// Merges the installer-archiving outcome into the version's ImeVersionHistory row
/// (same Merge idiom as the LastSeen update above). Fail-soft: the archive job
/// already logged its own result; a lost status column must never fail the worker.
/// </summary>
public async Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
{
if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(status)) return;
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeVersionHistory);
var mergeEntity = new TableEntity("Global", version)
{
["MsiArchiveStatus"] = status
};
if (!string.IsNullOrEmpty(blobPath)) mergeEntity["MsiArchiveBlobPath"] = blobPath;
if (!string.IsNullOrEmpty(sha256)) mergeEntity["MsiSha256"] = sha256;
if (sizeBytes.HasValue) mergeEntity["MsiBytes"] = sizeBytes.Value;
if (!string.IsNullOrEmpty(sourceUrl)) mergeEntity["MsiSourceUrl"] = sourceUrl;
await tableClient.UpsertEntityAsync(mergeEntity, TableUpdateMode.Merge);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to update ImeVersionHistory archive info for version {Version} (status {Status})",
version, status);
}
}
#endregion
#region Excessive-Event Detection
@@ -146,6 +146,9 @@ export const EVENT_TYPES_CATALOG = {
'agent_shutdown',
'agent_version_check',
'ime_agent_version',
'ime_token_failure',
'registry_app_state',
'app_state_reconciliation',
'ime_user_session_completed',
'ime_session_change',
'ime_process_exited',
@@ -416,6 +416,12 @@ namespace AutopilotMonitor.Shared
// IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9)
public const string EspPhaseChanged = "esp_phase_changed";
public const string ImeAgentVersion = "ime_agent_version";
public const string ImeTokenFailure = "ime_token_failure"; // Warning, once per distinct errorCode: IME token acquisition failed (IntuneTokenManager "Failed to get AAD token ... errorCode = N"). Complements IME-TOKEN-SUCCESS: an auth outage previously left every check-in silently doing nothing with no timeline evidence.
// Registry second pillar (ImeRegistryAppStateObserver, audit 2026-08-17): IME's authoritative
// per-app state read straight from HKLM\...\IntuneManagementExtension. Observability only —
// never consumed by the DecisionEngine.
public const string RegistryAppState = "registry_app_state"; // Info, state-change-only (snapshot-diff vs silent startup baseline; capped 200/session, cap announced once as Warning): per-app EnforcementState/ErrorCode/ExitCode/StatusService status/ESP-tracking from the registry.
public const string AppStateReconciliation = "app_state_reconciliation"; // Warning, once per app, immediate upload: registry terminal outcome (settled >= 90 s) diverges from the IME-log-derived app state — the built-in log-pattern drift alarm (registry = truth, logs = narrative).
public const string ImeUserSessionCompleted = "ime_user_session_completed";
public const string DoTelemetry = "do_telemetry";
public const string AllAppsCompleted = "all_apps_completed";
@@ -1295,6 +1301,19 @@ namespace AutopilotMonitor.Shared
/// settings (Soft-Delete + Versioning) provide defence-in-depth.
/// </summary>
public const string CriticalTableBackups = "critical-table-backups";
/// <summary>
/// Permanent platform archive of IME (Intune Management Extension) installer
/// binaries, one virtual folder per version:
/// <c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>.
/// Written by <c>ImeMsiArchiver</c> when a new IME version is first seen across
/// the fleet (companion of <see cref="TableNames.ImeVersionHistory"/>, which is
/// equally KeptByDesign). NOT tenant-scoped — blob paths are version-keyed, so
/// tenant offboarding (Phase 2.E) deliberately never touches this container.
/// No lifecycle rule: the archive is the point (build-to-build diffs of
/// Microsoft's rollouts). Private by default (SDK CreateIfNotExists).
/// </summary>
public const string ImeArchive = "ime-archive";
}
/// <summary>
@@ -1392,6 +1411,18 @@ namespace AutopilotMonitor.Shared
/// <summary>Poison sibling of <see cref="SessionDeletionMaintenance"/>.</summary>
public const string SessionDeletionMaintenancePoison = "session-deletion-maintenance-poison";
/// <summary>
/// IME-MSI archive fan-out. Producer = <c>EventIngestProcessor</c> when
/// <c>RecordImeVersionAsync</c> reports a genuinely NEW IME version (first sighting
/// across the fleet — roughly monthly); consumer = <c>ImeMsiArchiveQueueWorker</c>,
/// which downloads the installer from the CSP-reported
/// <c>msiDownloadUrl</c> (host-allowlisted, size-capped) into
/// <see cref="BlobContainers.ImeArchive"/> and merges the outcome into the
/// <see cref="TableNames.ImeVersionHistory"/> row. Poison suffix <c>-poison</c>,
/// max-dequeue 5.
/// </summary>
public const string ImeMsiArchive = "ime-msi-archive";
}
/// <summary>
@@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> RecordImeVersionAsync(string version, string tenantId, string sessionId);
Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync();
/// <summary>
/// Merges the installer-archiving outcome (<c>ImeMsiArchiver</c>) into the version's
/// <c>ImeVersionHistory</c> row. Fail-soft: storage errors are logged, never thrown.
/// Blob path / hash / size / URL are only written on success (null leaves them absent).
/// </summary>
Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl);
// --- Events ---
Task<bool> StoreEventAsync(EnrollmentEvent evt);
Task<List<EnrollmentEvent>> StoreEventsBatchAsync(List<EnrollmentEvent> events);
@@ -460,6 +460,24 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool AutoApproveNewTenants { get; set; } = false;
/// <summary>
/// When true, the first fleet-wide sighting of a new IME version enqueues an
/// archive job (<c>ime-msi-archive</c> queue) that downloads the installer from the
/// CSP-reported URL into the <c>ime-archive</c> blob container. Roughly one download
/// per Microsoft IME release (~monthly). Default true — flip off to pause archiving
/// (queued messages then wait, they are not dropped). Round-tripped via the 4-file
/// web chain (memory <c>feedback_admin_config_ui_roundtrip</c>).
/// </summary>
public bool ImeMsiArchivingEnabled { get; set; } = true;
/// <summary>
/// Size cap for the IME installer download in MB (Content-Length preflight AND
/// streamed-byte guard). The MSI is ~12 MB today; the generous cap exists only so a
/// tampered/wrong URL cannot stream gigabytes into the archive while still never
/// missing a legitimately grown installer. 0 = no limit. Default: 250 MB.
/// </summary>
public int MaxImeMsiDownloadSizeMB { get; set; } = 250;
/// <summary>
/// When true, the dual app-registration self-service homing flip is active: the Graph
/// consent flow funnels legacy-homed tenants to the primary (new) app registration and
@@ -0,0 +1,44 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Queue-message envelope for the <c>ime-msi-archive</c> queue. Enqueued by
/// <c>EventIngestProcessor</c> when an <c>ime_agent_version</c> event carries a version
/// the platform has never seen before (RecordImeVersionAsync insert succeeded). The
/// worker downloads the IME installer and archives it into the <c>ime-archive</c> blob
/// container so every fleet-observed IME build stays available for later decompilation
/// and build-to-build diffing, even after Microsoft's versionless CDN URL has moved on.
/// </summary>
public sealed class ImeMsiArchiveEnvelope
{
/// <summary>Schema version — bump on breaking envelope changes so consumers can reject or migrate.</summary>
public string EnvelopeVersion { get; set; } = "1";
/// <summary>The newly sighted IME version, e.g. <c>1.104.102.0</c> (blob folder name).</summary>
public string Version { get; set; } = string.Empty;
/// <summary>
/// The EnterpriseDesktopAppManagement-CSP <c>CurrentDownloadUrl</c> from the event's
/// <c>msiDownloadUrl</c> enrichment. Null when the reporting agent predates the
/// enrichment — the worker then falls back to the canonical versionless CDN URL
/// (safe: a brand-new version is by definition what that URL currently serves).
/// </summary>
public string? MsiDownloadUrl { get; set; }
/// <summary>
/// The event's <c>msiMatchedBy</c> companion (<c>productVersion</c> = URL is
/// version-authoritative, <c>fileName</c> = registry drift). Provenance only.
/// </summary>
public string? MsiMatchedBy { get; set; }
/// <summary>First-seen tenant — provenance only, never used for auth or paths.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>First-seen session — provenance only.</summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>UTC time the producer enqueued the message; useful for measuring queue lag.</summary>
public DateTime EnqueuedAt { get; set; }
}
}
@@ -575,5 +575,25 @@ namespace AutopilotMonitor.Shared.Models
public string FirstSeenTenantId { get; set; } = string.Empty;
public DateTime LastSeenAt { get; set; }
public int SessionCount { get; set; }
/// <summary>
/// Outcome of the automatic installer archiving (<c>ImeMsiArchiver</c>):
/// <c>Archived</c> or a <c>Failed:*</c> status; null for versions sighted before the
/// feature existed or while the archive job is still queued. Like the FirstSeen*
/// identifiers, the archive fields are only serialized for Global Admin callers.
/// </summary>
public string? MsiArchiveStatus { get; set; }
/// <summary>Blob path inside the <c>ime-archive</c> container, e.g. <c>1.104.102.0/IntuneWindowsAgent.msi</c>.</summary>
public string? MsiArchiveBlobPath { get; set; }
/// <summary>SHA-256 of the archived installer (hex, lowercase).</summary>
public string? MsiSha256 { get; set; }
/// <summary>Size of the archived installer in bytes.</summary>
public long? MsiBytes { get; set; }
/// <summary>The URL the installer was downloaded from (CSP-reported or canonical fallback).</summary>
public string? MsiSourceUrl { get; set; }
}
}
@@ -50,6 +50,10 @@ interface AdminConfigContextValue {
setAutoApproveNewTenants: (value: boolean) => void;
selfServiceAppHomingEnabled: boolean;
setSelfServiceAppHomingEnabled: (value: boolean) => void;
imeMsiArchivingEnabled: boolean;
setImeMsiArchivingEnabled: (value: boolean) => void;
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
// Diagnostics log paths
globalDiagPaths: DiagnosticsLogPath[];
@@ -152,6 +156,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [sessionDeletionKillSwitch, setSessionDeletionKillSwitch] = useState(false);
const [autoApproveNewTenants, setAutoApproveNewTenants] = useState(false);
const [selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled] = useState(false);
const [imeMsiArchivingEnabled, setImeMsiArchivingEnabled] = useState(true);
const [maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB] = useState(250);
// Diagnostics Log Paths state
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
@@ -215,6 +221,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setSessionDeletionKillSwitch(data.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(data.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(data.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(data.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(data.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -332,6 +340,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
sessionDeletionKillSwitch,
autoApproveNewTenants,
selfServiceAppHomingEnabled,
imeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB,
};
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
@@ -358,7 +368,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -379,6 +389,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(adminConfig.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(adminConfig.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(adminConfig.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -525,6 +537,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
opsAlertRules, setOpsAlertRules,
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
@@ -36,6 +36,10 @@ interface AdminConfigSettingsSectionProps {
setAutoApproveNewTenants: (value: boolean) => void;
selfServiceAppHomingEnabled: boolean;
setSelfServiceAppHomingEnabled: (value: boolean) => void;
imeMsiArchivingEnabled: boolean;
setImeMsiArchivingEnabled: (value: boolean) => void;
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
@@ -74,6 +78,10 @@ export function AdminConfigSettingsSection({
setAutoApproveNewTenants,
selfServiceAppHomingEnabled,
setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled,
setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB,
setMaxImeMsiDownloadSizeMB,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
@@ -375,6 +383,47 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={imeMsiArchivingEnabled}
onChange={(e) => setImeMsiArchivingEnabled(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 accent-indigo-600 focus:ring-indigo-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer archiving</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
When <strong>on</strong>, the first fleet-wide sighting of a new IME version downloads
the installer (from the CSP-reported, allowlisted URL) into the permanent
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ime-archive</code> blob
container — roughly one small download per Microsoft IME release. The outcome is
visible per version in the IME version history. When <strong>off</strong>, queued
archive jobs wait (they are not dropped). Default <strong>on</strong>.
</p>
</span>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer download size cap (MB)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
Safety cap for the IME installer download (the MSI is ~12 MB today). Generous on
purpose so a legitimately grown installer is never missed, while a wrong URL can
still not stream gigabytes into the archive. 0 = unlimited. Default 250.
</p>
<input
type="number"
min="0"
max="10000"
value={maxImeMsiDownloadSizeMB}
onChange={(e) => setMaxImeMsiDownloadSizeMB(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
@@ -24,6 +24,8 @@ export function SectionGlobalSettings() {
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
@@ -66,6 +68,10 @@ export function SectionGlobalSettings() {
setAutoApproveNewTenants={setAutoApproveNewTenants}
selfServiceAppHomingEnabled={selfServiceAppHomingEnabled}
setSelfServiceAppHomingEnabled={setSelfServiceAppHomingEnabled}
imeMsiArchivingEnabled={imeMsiArchivingEnabled}
setImeMsiArchivingEnabled={setImeMsiArchivingEnabled}
maxImeMsiDownloadSizeMB={maxImeMsiDownloadSizeMB}
setMaxImeMsiDownloadSizeMB={setMaxImeMsiDownloadSizeMB}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
@@ -83,6 +83,8 @@ interface DownloadItem {
downloadRateBps: number;
lastUpdated: string;
lastUpdatedMs: number;
startedMs: number;
completedMs: number | null;
isComplete: boolean;
isSkipped: boolean;
firstSeenIndex: number;
@@ -118,6 +120,19 @@ function formatDoDuration(duration: string): string {
return formatDuration(Math.floor(ms / 1000));
}
// Per-app download duration. Prefers the DO-reported duration ([DO TEL] JSON,
// present up to IME ~1.97); IME 1.104 removed that log line entirely, so fall
// back to the observed event window: first download event -> first event that
// marked the download complete.
function effectiveDurationMs(dl: DownloadItem): number {
const doMs = dl.doStats?.downloadDuration ? parseDoDurationMs(dl.doStats.downloadDuration) : 0;
if (doMs > 0) return doMs;
if (dl.completedMs != null && Number.isFinite(dl.startedMs) && dl.completedMs > dl.startedMs) {
return dl.completedMs - dl.startedMs;
}
return 0;
}
export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) {
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
@@ -219,6 +234,8 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
// memo-recompute would be an arbitrary base for event-time deltas
// anyway (render must also stay pure — react-hooks/purity).
lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? NaN),
startedMs: existing?.startedMs ?? (Number.isFinite(eventTs) ? eventTs : NaN),
completedMs: existing?.completedMs ?? (isComplete && Number.isFinite(eventTs) ? eventTs : null),
isComplete,
isSkipped: isSkippedEvent || (existing?.isSkipped ?? false),
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
@@ -237,18 +254,17 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
const [expanded, setExpanded] = useState(true);
const [showSkipped, setShowSkipped] = useState(false);
// Sum of individual download durations (from DO telemetry per app)
// Sum of individual download durations (DO telemetry when present, otherwise
// the observed start->complete event window per app).
// Must be before the early return to keep hooks in stable order across renders.
const totalDuration = useMemo(() => {
let sum = 0;
let hasAny = false;
for (const dl of downloads) {
if (dl.doStats?.downloadDuration) {
const ms = parseDoDurationMs(dl.doStats.downloadDuration);
if (ms > 0) {
sum += ms;
hasAny = true;
}
const ms = effectiveDurationMs(dl);
if (ms > 0) {
sum += ms;
hasAny = true;
}
}
return hasAny ? sum : null;
@@ -365,6 +381,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
const [showDetails, setShowDetails] = useState(false);
const [showDoStats, setShowDoStats] = useState(false);
const hasKnownTotal = dl.bytesTotal > 0;
const durationMs = effectiveDurationMs(dl);
const showProgressBar = !dl.isSkipped && (hasKnownTotal || dl.isComplete || !dl.isComplete);
// Determine container styling
@@ -429,6 +446,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
: dl.isComplete
? "Completed"
: `${formatBytes(dl.bytesDownloaded)} downloaded`}
{dl.isComplete && durationMs > 0 && (
<span className="text-gray-400"> in {formatDuration(Math.floor(durationMs / 1000))}</span>
)}
</span>
<span>{progressPercent > 0 ? `${progressPercent.toFixed(0)}%` : "started"}</span>
</div>
@@ -479,7 +499,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
<div>
<span className="text-gray-500">Duration:</span>{" "}
<span className="font-medium">{formatDoDuration(dl.doStats.downloadDuration)}</span>
<span className="font-medium">
{durationMs > 0 ? formatDuration(Math.floor(durationMs / 1000)) : formatDoDuration(dl.doStats.downloadDuration)}
</span>
</div>
<div>
<span className="text-gray-500">File Size:</span>{" "}
@@ -99,6 +99,17 @@ export interface AdminConfiguration {
* only Global Admins can flip while it is off. Default false.
*/
selfServiceAppHomingEnabled?: boolean;
/**
* When true, the first fleet-wide sighting of a new IME version downloads the
* installer into the permanent ime-archive blob container (ime-msi-archive queue
* worker). When false, queued archive jobs wait — they are not dropped. Default true.
*/
imeMsiArchivingEnabled?: boolean;
/**
* Size cap for the IME installer download in MB (preflight + mid-stream guard).
* 0 = unlimited. Default 250.
*/
maxImeMsiDownloadSizeMB?: number;
}
export interface OpsAlertRule {
@@ -50,6 +50,8 @@ export const SHARED_MANIFEST = {
"vulnerabilityCorrelationEnabled",
"enableIndexDualWrite",
"autoApproveNewTenants",
"imeMsiArchivingEnabled",
"maxImeMsiDownloadSizeMB",
"selfServiceAppHomingEnabled",
"sessionDeletionKillSwitch",
"vulnerabilityDataLastSyncUtc",
@@ -486,6 +488,7 @@ export const SHARED_MANIFEST = {
"app_install_skipped",
"app_install_started",
"app_install_starved",
"app_state_reconciliation",
"app_tracking_summary",
"autologon_analysis",
"autopilot_profile",
@@ -557,6 +560,7 @@ export const SHARED_MANIFEST = {
"ime_agent_version",
"ime_process_exited",
"ime_session_change",
"ime_token_failure",
"ime_user_session_completed",
"ingress_backpressure",
"integrity_bypass_analysis",
@@ -600,6 +604,7 @@ export const SHARED_MANIFEST = {
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",
@@ -46,6 +46,8 @@
"vulnerabilityCorrelationEnabled",
"enableIndexDualWrite",
"autoApproveNewTenants",
"imeMsiArchivingEnabled",
"maxImeMsiDownloadSizeMB",
"selfServiceAppHomingEnabled",
"sessionDeletionKillSwitch",
"vulnerabilityDataLastSyncUtc",
@@ -482,6 +484,7 @@
"app_install_skipped",
"app_install_started",
"app_install_starved",
"app_state_reconciliation",
"app_tracking_summary",
"autologon_analysis",
"autopilot_profile",
@@ -553,6 +556,7 @@
"ime_agent_version",
"ime_process_exited",
"ime_session_change",
"ime_token_failure",
"ime_user_session_completed",
"ingress_backpressure",
"integrity_bypass_analysis",
@@ -596,6 +600,7 @@
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",