diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SoftwareInventoryAnalyzerIdempotencyTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SoftwareInventoryAnalyzerIdempotencyTests.cs
new file mode 100644
index 00000000..1c2b532c
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SoftwareInventoryAnalyzerIdempotencyTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
+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 AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
+{
+ ///
+ /// Idempotency guards on .
+ /// The Part-1 shutdown snapshot is fired by two independent paths
+ /// ( and
+ /// EnrollmentTerminationHandler.RunShutdownAnalyzers at the WhiteGloveSealed exit) —
+ /// without the per-phase guard the analyzer would emit two shutdown events for the same
+ /// session, which the backend would translate to two queue messages and two correlation
+ /// runs. The guard wins single-fire while keeping different phases independent.
+ ///
+ /// These tests exercise the real end-to-end (it
+ /// reads the local Uninstall registry hives during CollectAndNormalize). They are
+ /// intentionally lightweight: assertions count software_inventory_analysis events
+ /// posted to the fake ingress, never the inventory contents themselves.
+ ///
+ ///
+ public sealed class SoftwareInventoryAnalyzerIdempotencyTests : IDisposable
+ {
+ private readonly TempDirectory _tmp = new();
+ private readonly AgentLogger _logger;
+ private readonly FakeSignalIngressSink _ingress = new();
+ private readonly VirtualClock _clock = new(new DateTime(2026, 4, 30, 8, 45, 0, DateTimeKind.Utc));
+ private readonly SoftwareInventoryAnalyzer _sut;
+
+ public SoftwareInventoryAnalyzerIdempotencyTests()
+ {
+ _logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ var post = new InformationalEventPost(_ingress, _clock);
+ _sut = new SoftwareInventoryAnalyzer(
+ sessionId: "session-1",
+ tenantId: "tenant-1",
+ post: post,
+ logger: _logger);
+ }
+
+ public void Dispose()
+ {
+ try { _tmp.Dispose(); } catch { }
+ }
+
+ [Fact]
+ public void AnalyzeAtShutdown_Part1_called_twice_emits_only_one_event()
+ {
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 1);
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 1); // second call — must be a no-op
+
+ Assert.Equal(1, CountChunkZeroEventsForPart(part: 1));
+ }
+
+ [Fact]
+ public void AnalyzeAtShutdown_Part2_called_after_Part1_runs_independently()
+ {
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 1);
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 2);
+
+ Assert.Equal(1, CountChunkZeroEventsForPart(part: 1));
+ Assert.Equal(1, CountChunkZeroEventsForPart(part: 2));
+ }
+
+ [Fact]
+ public void AnalyzeAtShutdown_Part2_called_twice_emits_only_one_event()
+ {
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 2);
+ _sut.AnalyzeAtShutdown(whiteGlovePart: 2);
+
+ Assert.Equal(1, CountChunkZeroEventsForPart(part: 2));
+ }
+
+ ///
+ /// Counts the chunk-0 software_inventory_analysis events for a given WG part.
+ /// EnrollmentEvent.Data is forwarded as TypedPayload by InformationalEventPost; the
+ /// inventory analyzer always emits at least one chunk-0 event per shutdown call, so a
+ /// chunk-0 count of 1 means the call was non-deduped, 0 means deduped, 2+ means the
+ /// guard failed.
+ ///
+ private int CountChunkZeroEventsForPart(int part) =>
+ _ingress.Posted
+ .Where(p =>
+ p.Kind == DecisionSignalKind.InformationalEvent
+ && p.Payload != null
+ && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
+ && et == "software_inventory_analysis"
+ && p.TypedPayload is IDictionary data
+ && data.TryGetValue("triggered_at", out var ta) && ta?.ToString() == "shutdown"
+ && data.TryGetValue("chunk_index", out var ci) && Convert.ToInt32(ci) == 0
+ && data.TryGetValue("whiteglove_part", out var wp) && Convert.ToInt32(wp) == part)
+ .Count();
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/WhiteGloveInventoryTriggerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/WhiteGloveInventoryTriggerTests.cs
new file mode 100644
index 00000000..38c59068
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/WhiteGloveInventoryTriggerTests.cs
@@ -0,0 +1,136 @@
+#nullable enable
+using System;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
+
+///
+/// Unit tests for . Verifies single-fire
+/// dedup, action-on-event, and clean unsubscribe-on-dispose. The trigger is the V2
+/// agent companion of backend's vulnerability-correlate queue (PR1+PR2): it fires the
+/// Part-1 software-inventory snapshot while the OOBE Reseal dialog is still up.
+///
+public sealed class WhiteGloveInventoryTriggerTests : IDisposable
+{
+ private readonly TempDirectory _tmp = new();
+ private readonly AgentLogger _logger;
+
+ public WhiteGloveInventoryTriggerTests()
+ {
+ _logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
+ }
+
+ public void Dispose()
+ {
+ try { _tmp.Dispose(); } catch { }
+ }
+
+ [Fact]
+ public void OnWhiteGloveCompleted_invokes_trigger_action_once()
+ {
+ var source = new FakeWhiteGloveSource();
+ var triggerCount = 0;
+ using var sut = new WhiteGloveInventoryTrigger(
+ host: source,
+ onTrigger: () => triggerCount++,
+ logger: _logger);
+
+ source.RaiseWhiteGloveCompleted();
+
+ Assert.Equal(1, triggerCount);
+ }
+
+ [Fact]
+ public void OnWhiteGloveCompleted_dedups_duplicate_event_emissions()
+ {
+ var source = new FakeWhiteGloveSource();
+ var triggerCount = 0;
+ using var sut = new WhiteGloveInventoryTrigger(
+ host: source,
+ onTrigger: () => triggerCount++,
+ logger: _logger);
+
+ source.RaiseWhiteGloveCompleted();
+ source.RaiseWhiteGloveCompleted();
+ source.RaiseWhiteGloveCompleted();
+
+ Assert.Equal(1, triggerCount);
+ }
+
+ [Fact]
+ public void Dispose_unsubscribes_so_late_events_are_ignored()
+ {
+ var source = new FakeWhiteGloveSource();
+ var triggerCount = 0;
+ var sut = new WhiteGloveInventoryTrigger(
+ host: source,
+ onTrigger: () => triggerCount++,
+ logger: _logger);
+
+ sut.Dispose();
+ source.RaiseWhiteGloveCompleted();
+
+ Assert.Equal(0, triggerCount);
+ }
+
+ [Fact]
+ public void Dispose_is_idempotent()
+ {
+ var source = new FakeWhiteGloveSource();
+ using var sut = new WhiteGloveInventoryTrigger(
+ host: source,
+ onTrigger: () => { },
+ logger: _logger);
+
+ sut.Dispose();
+ sut.Dispose(); // must not throw or double-unsubscribe
+ }
+
+ [Fact]
+ public void Trigger_action_exception_does_not_propagate()
+ {
+ var source = new FakeWhiteGloveSource();
+ using var sut = new WhiteGloveInventoryTrigger(
+ host: source,
+ onTrigger: () => throw new InvalidOperationException("boom"),
+ logger: _logger);
+
+ // Must not surface to the source's event-loop — the agent must keep running
+ // even if the analyzer manager is mid-shutdown when the trigger fires.
+ var ex = Record.Exception(() => source.RaiseWhiteGloveCompleted());
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void Constructor_throws_on_null_host()
+ {
+ Assert.Throws(() =>
+ new WhiteGloveInventoryTrigger(host: null!, onTrigger: () => { }, logger: _logger));
+ }
+
+ [Fact]
+ public void Constructor_throws_on_null_action()
+ {
+ var source = new FakeWhiteGloveSource();
+ Assert.Throws(() =>
+ new WhiteGloveInventoryTrigger(host: source, onTrigger: null!, logger: _logger));
+ }
+
+ [Fact]
+ public void Constructor_throws_on_null_logger()
+ {
+ var source = new FakeWhiteGloveSource();
+ Assert.Throws(() =>
+ new WhiteGloveInventoryTrigger(host: source, onTrigger: () => { }, logger: null!));
+ }
+
+ private sealed class FakeWhiteGloveSource : IWhiteGloveCompletedSource
+ {
+ public event EventHandler? WhiteGloveCompleted;
+
+ public void RaiseWhiteGloveCompleted() => WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs
index 90b64eb2..9dd8c532 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
@@ -29,6 +30,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
// Captured at startup for delta detection at shutdown
private List _startupInventory;
+ // Idempotency guard — the WhiteGlove-Part-1 shutdown snapshot is fired by TWO
+ // independent paths: WhiteGloveInventoryTrigger (preferred, fires on Windows
+ // Event 62407 immediately) and EnrollmentTerminationHandler.RunShutdown(wgPart=1)
+ // (fallback when the WG-success event never arrives). The two paths can race —
+ // both check the guard, both pass it, both emit duplicate snapshots. The fix is
+ // an atomic Interlocked.CompareExchange that wins-or-loses up-front, BEFORE the
+ // work starts. On unhandled work failure the flag is reset so the second path
+ // can still serve as the fallback (registry-read errors, transient I/O, etc.)
+ // — clean failure semantics: a successful emit locks the flag forever, a
+ // failed attempt frees it.
+ // Encoded as int (0/1) instead of bool because Interlocked.CompareExchange has
+ // no bool overload.
+ private int _part1ShutdownSnapshotEmitted;
+ private int _part2ShutdownSnapshotEmitted;
+
public string Name => "SoftwareInventoryAnalyzer";
// Maximum items per event chunk to stay within Table Storage property size limits (~64 KB)
@@ -96,6 +112,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
///
public void AnalyzeAtShutdown(int? whiteGlovePart)
{
+ // Per-WG-phase atomic claim. Set the flag BEFORE the work via CompareExchange
+ // so two concurrent callers (e.g. WhiteGloveInventoryTrigger and
+ // EnrollmentTerminationHandler firing nearly simultaneously) cannot both pass
+ // the guard. The loser logs and returns. Non-WG shutdowns (whiteGlovePart=null)
+ // skip the claim because there is exactly one such call per agent lifetime by
+ // construction.
+ if (whiteGlovePart == 1)
+ {
+ if (Interlocked.CompareExchange(ref _part1ShutdownSnapshotEmitted, 1, 0) != 0)
+ {
+ _logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=1) skipped — Part-1 snapshot already emitted (likely by WhiteGloveInventoryTrigger)");
+ return;
+ }
+ }
+ else if (whiteGlovePart == 2)
+ {
+ if (Interlocked.CompareExchange(ref _part2ShutdownSnapshotEmitted, 1, 0) != 0)
+ {
+ _logger.Info($"{Name}: AnalyzeAtShutdown(whiteGlovePart=2) skipped — Part-2 snapshot already emitted");
+ return;
+ }
+ }
+
_logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
try
{
@@ -106,6 +145,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
catch (Exception ex)
{
_logger.Error($"{Name}: Shutdown analysis failed", ex);
+
+ // Reset the flag so the fallback path (e.g. EnrollmentTerminationHandler
+ // running after a Trigger-side failure) can still emit the snapshot.
+ // Successful emits keep the flag set forever — duplicate suppression wins.
+ if (whiteGlovePart == 1) Interlocked.Exchange(ref _part1ShutdownSnapshotEmitted, 0);
+ else if (whiteGlovePart == 2) Interlocked.Exchange(ref _part2ShutdownSnapshotEmitted, 0);
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
index 97934d43..8d4dbd54 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
@@ -68,8 +68,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly string _stateDirectory;
private ImeLogHost? _imeLogHost;
+ private EspAndHelloHost? _espAndHelloHost;
private Transport.Telemetry.ITelemetrySpool? _telemetrySpool;
+ ///
+ /// Exposes the WhiteGlove-success event surface from the ESP/Hello coordinator host so
+ /// downstream subscribers (e.g. WhiteGloveInventoryTrigger) can react to the
+ /// pre-provisioning success window without reaching into the host's internals. Returns
+ /// null before has been called.
+ ///
+ /// internal because itself is internal sealed.
+ /// AutopilotMonitor.Agent.V2 (the runtime entry-point project) sees it via the
+ /// InternalsVisibleTo declared on this project's csproj.
+ ///
+ ///
+ internal EspAndHelloHost? EspAndHelloHost => _espAndHelloHost;
+
///
/// Exposes the IME tracker's package-state list to peripheral consumers such as the
/// FinalStatusBuilder in M4.6.β. Returns null before
@@ -149,7 +163,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
- hosts.Add(new EspAndHelloHost(
+ _espAndHelloHost = new EspAndHelloHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
@@ -161,7 +175,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled,
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
- stateDirectory: _stateDirectory));
+ stateDirectory: _stateDirectory);
+ hosts.Add(_espAndHelloHost);
hosts.Add(new DesktopArrivalHost(logger, ingress, clock));
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs
index 62ff071b..b2c9bd82 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs
@@ -8,7 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
- internal sealed class EspAndHelloHost : ICollectorHost
+ internal sealed class EspAndHelloHost : ICollectorHost, IWhiteGloveCompletedSource
{
public string Name => "EspAndHelloTracker";
@@ -16,6 +16,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly EspAndHelloTrackerAdapter _adapter;
private int _disposed;
+ ///
+ /// Forwarded from the internal
+ /// event so external components (e.g. WhiteGloveInventoryTrigger) can react
+ /// to WhiteGlove pre-provisioning success without needing direct access to the
+ /// (private) tracker. Fires after the agent observes Windows Event 62407
+ /// ("BootstrapStatus: Exiting page due to White Glove success") — i.e. while the
+ /// "Continue / Reseal" dialog is shown but BEFORE the admin clicks Reseal and the
+ /// Sysprep reboot fires. This is the only window in which WhiteGlove Part 1 work
+ /// (e.g. inventory snapshot for vulnerability correlation) can run.
+ ///
+ public event EventHandler? WhiteGloveCompleted;
+
public EspAndHelloHost(
string sessionId,
string tenantId,
@@ -46,15 +58,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
stateDirectory: stateDirectory,
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds);
+ _tracker.WhiteGloveCompleted += OnTrackerWhiteGloveCompleted;
_adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock);
}
public void Start() => _tracker.Start();
public void Stop() => _tracker.Stop();
+ private void OnTrackerWhiteGloveCompleted(object sender, EventArgs e)
+ => WhiteGloveCompleted?.Invoke(this, e);
+
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ try { _tracker.WhiteGloveCompleted -= OnTrackerWhiteGloveCompleted; } catch { }
try { _adapter.Dispose(); } catch { }
try { _tracker.Dispose(); } catch { }
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IWhiteGloveCompletedSource.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IWhiteGloveCompletedSource.cs
new file mode 100644
index 00000000..844d3956
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/IWhiteGloveCompletedSource.cs
@@ -0,0 +1,20 @@
+#nullable enable
+using System;
+
+namespace AutopilotMonitor.Agent.V2.Core.Orchestration
+{
+ ///
+ /// Surface for components that observe the Shell-Core WhiteGlove pre-provisioning success
+ /// signal (Windows Event 62407) and need to forward it to dependents that don't share the
+ /// host's lifecycle directly. Implemented by in production
+ /// and by tiny test stubs in unit tests.
+ ///
+ internal interface IWhiteGloveCompletedSource
+ {
+ ///
+ /// Fires when the agent observes a successful WhiteGlove sealing — i.e. while the
+ /// OOBE "Continue / Reseal" dialog is shown and the admin has not yet clicked Reseal.
+ ///
+ event EventHandler? WhiteGloveCompleted;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/WhiteGloveInventoryTrigger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/WhiteGloveInventoryTrigger.cs
new file mode 100644
index 00000000..9d647984
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/WhiteGloveInventoryTrigger.cs
@@ -0,0 +1,85 @@
+#nullable enable
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+
+namespace AutopilotMonitor.Agent.V2.Core.Orchestration
+{
+ ///
+ /// Subscribes to and triggers a single
+ /// inventory snapshot tagged with whiteGlovePart=1 while the OOBE "Continue / Reseal"
+ /// dialog is shown — i.e. BEFORE the admin clicks Reseal and Sysprep reboots the device.
+ /// This is the only window in which the agent can stage a Part-1 software-inventory event
+ /// for downstream vulnerability correlation; once Sysprep starts the agent is killed
+ /// (previousExitType=reboot_kill on the next start) and any pending Part-1 work is lost
+ /// from the live spool (it would only replay on the Part-2 agent start, by which point the
+ /// inventory snapshot has missed its WhiteGlove-Part-1 phase tag).
+ ///
+ /// Lifecycle: Constructor subscribes; unsubscribes. The trigger
+ /// is single-fire — a defensive guards
+ /// against duplicate WhiteGloveCompleted emissions (the V2 tracker emits at most
+ /// once today, but the guard makes the contract explicit).
+ ///
+ ///
+ /// Action injection: The trigger calls a caller-supplied rather
+ /// than holding a direct reference to AgentAnalyzerManager. This keeps the trigger
+ /// trivially testable (no concrete-class mock needed) and avoids a Core→Runtime coupling.
+ /// In production wiring the action is () => analyzerManager.RunWhiteGlovePart1InventorySnapshot().
+ ///
+ ///
+ /// Routing separation: This trigger is intentionally orthogonal to the
+ /// that maps the same WG-success
+ /// event into a WhiteGloveShellCoreSuccess decision signal. Both subscribers see
+ /// the event independently — single-responsibility, easy to remove.
+ ///
+ ///
+ internal sealed class WhiteGloveInventoryTrigger : IDisposable
+ {
+ private readonly IWhiteGloveCompletedSource _host;
+ private readonly Action _onTrigger;
+ private readonly AgentLogger _logger;
+
+ private int _fired; // 0 = not yet fired, 1 = fired
+ private int _disposed; // 0 = live, 1 = disposed
+
+ public WhiteGloveInventoryTrigger(
+ IWhiteGloveCompletedSource host,
+ Action onTrigger,
+ AgentLogger logger)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _onTrigger = onTrigger ?? throw new ArgumentNullException(nameof(onTrigger));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ _host.WhiteGloveCompleted += OnWhiteGloveCompleted;
+ }
+
+ private void OnWhiteGloveCompleted(object? sender, EventArgs e)
+ {
+ if (Interlocked.Exchange(ref _fired, 1) == 1)
+ {
+ _logger.Info("WhiteGloveInventoryTrigger: duplicate WhiteGloveCompleted ignored (already fired)");
+ return;
+ }
+
+ _logger.Info("WhiteGloveInventoryTrigger: WhiteGloveCompleted observed — running Part-1 inventory snapshot");
+
+ try
+ {
+ _onTrigger();
+ }
+ catch (Exception ex)
+ {
+ // Trigger action failures must not propagate into the tracker's event loop.
+ // Inventory missing is a degraded outcome but the agent must keep running.
+ _logger.Error("WhiteGloveInventoryTrigger: trigger action threw", ex);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ try { _host.WhiteGloveCompleted -= OnWhiteGloveCompleted; } catch { }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs
index e01e2a8f..c049d67c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs
@@ -148,5 +148,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
}
}
}
+
+ ///
+ /// Runs ONLY the shutdown snapshot tagged with
+ /// whiteGlovePart=1. Triggered by WhiteGloveInventoryTrigger when the
+ /// agent observes the WhiteGlove pre-provisioning success event (Windows Event 62407)
+ /// — i.e. while the OOBE "Continue / Reseal" dialog is shown but BEFORE the admin
+ /// clicks Reseal and Sysprep reboots the box.
+ ///
+ /// Deliberately does NOT run or
+ /// : those snapshots are meaningful at a real
+ /// final agent shutdown. At the WG-Part-1 hand-off the system is still in SYSTEM
+ /// context with no real user yet — those analyzers would either no-op or report
+ /// noise. They run normally at the eventual final shutdown via
+ /// .
+ ///
+ ///
+ public void RunWhiteGlovePart1InventorySnapshot()
+ {
+ if (!_initialised) Initialize();
+ if (_analyzers.Count == 0) return;
+
+ foreach (var analyzer in _analyzers)
+ {
+ if (analyzer is not SoftwareInventoryAnalyzer softwareAnalyzer) continue;
+
+ try
+ {
+ _logger.Info("WhiteGlove Part 1 inventory snapshot — running SoftwareInventoryAnalyzer.AnalyzeAtShutdown(whiteGlovePart: 1)");
+ softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart: 1);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("SoftwareInventoryAnalyzer threw during WhiteGlove Part 1 snapshot", ex);
+ }
+ return; // SoftwareInventoryAnalyzer is registered at most once
+ }
+
+ _logger.Warning("WhiteGlove Part 1 inventory snapshot requested but SoftwareInventoryAnalyzer is not registered (disabled by remote config?)");
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
index 8334daf3..5b585217 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
@@ -263,7 +263,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// WhiteGlove Part 1 exit passes whiteGlovePart=1 so SoftwareInventoryAnalyzer
// takes a baseline snapshot rather than computing a pre/post delta (the user
// sign-in phase has not run yet; the real delta computes on Part-2 completion).
- int? wgPart = args.StageName == SessionStage.WhiteGloveSealed.ToString() ? 1 : (int?)null;
+ // WhiteGlove Part 2 exit passes whiteGlovePart=2 so the backend's vulnerability
+ // correlation pipeline can filter Part-2 inventory out of the Part-1 set when
+ // re-correlating, and tag findings with the correct enrollment phase.
+ // SoftwareInventoryAnalyzer's per-phase idempotency guards make a second
+ // Part-1 call (when WhiteGloveInventoryTrigger fired earlier) a no-op for the
+ // inventory analyzer while still running LocalAdmin / IntegrityBypass.
+ int? wgPart;
+ if (args.StageName == SessionStage.WhiteGloveSealed.ToString())
+ wgPart = 1;
+ else if (args.StageName == SessionStage.WhiteGloveCompletedPart2.ToString())
+ wgPart = 2;
+ else
+ wgPart = null;
_analyzerManager.RunShutdown(wgPart);
}
catch (Exception ex)
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
index 521183fd..d5dbe182 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
@@ -136,6 +136,15 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EnrollmentTerminationHandler terminationHandler = null;
ServerActionDispatcher serverActionDispatcher = null;
+ // WhiteGlove Part-1 inventory trigger (vulnerability-correlation pipeline,
+ // companion of backend's vulnerability-correlate queue). Subscribes to the
+ // Shell-Core WG-success event so the agent can stage a software_inventory_analysis
+ // event with whiteglove_part=1 BEFORE Sysprep reboots the box on Reseal.
+ // Constructed AFTER orchestrator.Start so EspAndHelloHost (created inside Start
+ // at step 14) and analyzerManager (created in the onIngressReady hook at step 13b)
+ // are both live. Disposed in the same finally block as the rest of the lifecycle.
+ WhiteGloveInventoryTrigger whiteGloveInventoryTrigger = null;
+
ConsoleCancelEventHandler cancelHandler = (s, e) =>
{
e.Cancel = true;
@@ -272,6 +281,23 @@ namespace AutopilotMonitor.Agent.V2.Runtime
shutdownComplete,
logger);
+ // Wire the WhiteGlove Part-1 inventory trigger. componentFactory.EspAndHelloHost
+ // is set inside CreateCollectorHosts (orchestrator.Start step 14, runs after
+ // the onIngressReady hook above), so by here both it and analyzerManager are
+ // live. The Action closes over the local `analyzerManager` variable; null-safe
+ // in case a race ever inverts the order.
+ if (componentFactory.EspAndHelloHost != null)
+ {
+ whiteGloveInventoryTrigger = new WhiteGloveInventoryTrigger(
+ host: componentFactory.EspAndHelloHost,
+ onTrigger: () => analyzerManager?.RunWhiteGlovePart1InventorySnapshot(),
+ logger: logger);
+ }
+ else
+ {
+ logger.Warning("WhiteGloveInventoryTrigger not wired — componentFactory.EspAndHelloHost is null after orchestrator.Start.");
+ }
+
// WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
// SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge —
// that reducer effect emits whiteglove_resumed on the timeline. We
@@ -369,6 +395,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
finally
{
+ // Detach the WG-Part-1 inventory trigger before TerminationPipeline runs
+ // so any stragglers fired during shutdown don't reach a half-torn-down
+ // analyzerManager. Idempotent: Dispose unsubscribes via Interlocked guard.
+ try { whiteGloveInventoryTrigger?.Dispose(); }
+ catch (Exception ex) { logger.Warning($"WhiteGloveInventoryTrigger.Dispose threw: {ex.Message}"); }
+
TerminationPipeline.Run(
orchestrator: orchestrator,
authFailureTracker: auth.AuthFailureTracker,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index ccbd68fb..bf2c10d2 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-460
+477
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 1f8248ce..9e3410c0 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1212
+1219
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateEnvelopeSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateEnvelopeSerializationTests.cs
new file mode 100644
index 00000000..49df00a3
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateEnvelopeSerializationTests.cs
@@ -0,0 +1,94 @@
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// JSON round-trip tests for . The producer
+/// (AzureQueueVulnerabilityCorrelateProducer) serializes with Newtonsoft and the
+/// queue worker (VulnerabilityCorrelateQueueWorker) deserializes the same shape —
+/// pinning the contract here keeps the queue from silently breaking when fields are added.
+///
+public class VulnerabilityCorrelateEnvelopeSerializationTests
+{
+ private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ private static readonly DateTime EnqueuedAt =
+ new(2026, 4, 30, 9, 48, 58, DateTimeKind.Utc);
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void Envelope_round_trips_through_newtonsoft_for_all_white_glove_parts(int? whiteGlovePart)
+ {
+ var original = new VulnerabilityCorrelateEnvelope
+ {
+ EnvelopeVersion = "1",
+ TenantId = TenantId,
+ SessionId = SessionId,
+ WhiteGlovePart = whiteGlovePart,
+ Reason = "shutdown_inventory",
+ EnqueuedAt = EnqueuedAt,
+ };
+
+ var json = JsonConvert.SerializeObject(original);
+ var restored = JsonConvert.DeserializeObject(json);
+
+ Assert.NotNull(restored);
+ Assert.Equal("1", restored!.EnvelopeVersion);
+ Assert.Equal(TenantId, restored.TenantId);
+ Assert.Equal(SessionId, restored.SessionId);
+ Assert.Equal(whiteGlovePart, restored.WhiteGlovePart);
+ Assert.Equal("shutdown_inventory", restored.Reason);
+ Assert.Equal(EnqueuedAt, restored.EnqueuedAt.ToUniversalTime());
+ }
+
+ [Fact]
+ public void Envelope_default_envelope_version_is_one()
+ {
+ var envelope = new VulnerabilityCorrelateEnvelope();
+ Assert.Equal("1", envelope.EnvelopeVersion);
+ }
+
+ [Fact]
+ public void Envelope_default_strings_are_empty_not_null()
+ {
+ var envelope = new VulnerabilityCorrelateEnvelope();
+ Assert.Equal(string.Empty, envelope.TenantId);
+ Assert.Equal(string.Empty, envelope.SessionId);
+ Assert.Equal(string.Empty, envelope.Reason);
+ }
+
+ [Fact]
+ public void Envelope_default_white_glove_part_is_null()
+ {
+ var envelope = new VulnerabilityCorrelateEnvelope();
+ Assert.Null(envelope.WhiteGlovePart);
+ }
+
+ [Fact]
+ public void Envelope_serializes_all_fields_on_wire()
+ {
+ // Pin the JSON wire-format so a property rename can't slip through unnoticed.
+ var envelope = new VulnerabilityCorrelateEnvelope
+ {
+ TenantId = TenantId,
+ SessionId = SessionId,
+ WhiteGlovePart = 1,
+ Reason = "whiteglove_part1_inventory",
+ EnqueuedAt = EnqueuedAt,
+ };
+
+ var json = JsonConvert.SerializeObject(envelope);
+
+ Assert.Contains("\"TenantId\"", json);
+ Assert.Contains("\"SessionId\"", json);
+ Assert.Contains("\"WhiteGlovePart\"", json);
+ Assert.Contains("\"Reason\"", json);
+ Assert.Contains("\"EnqueuedAt\"", json);
+ Assert.Contains("\"EnvelopeVersion\"", json);
+ Assert.Contains("whiteglove_part1_inventory", json);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateHandlerSkipTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateHandlerSkipTests.cs
new file mode 100644
index 00000000..c9f055b5
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/VulnerabilityCorrelateHandlerSkipTests.cs
@@ -0,0 +1,244 @@
+using AutopilotMonitor.Functions.Services.Vulnerability;
+using Newtonsoft.Json.Linq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Unit tests for the static ContainsUserEnrollmentFindings helper used by
+/// to detect out-of-order delivery —
+/// specifically a Part-1 queue message arriving AFTER a Part-2 message has already
+/// stored the combined / Part-2-only report. In that case the Part-1 handler skips
+/// its store to avoid clobbering the richer report.
+///
+/// The helper has to handle both shapes
+/// deserialization can produce: native
+/// values and Newtonsoft / values that
+/// come back when the report is round-tripped through stored JSON.
+///
+///
+public class VulnerabilityCorrelateHandlerSkipTests
+{
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_false_for_empty_report()
+ {
+ var report = new Dictionary();
+ Assert.False(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_false_when_findings_null()
+ {
+ var report = new Dictionary { ["findings"] = null! };
+ Assert.False(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_false_for_part1_only_native_report()
+ {
+ var report = new Dictionary
+ {
+ ["findings"] = new List>
+ {
+ new() { ["softwareName"] = "Edge", ["phase"] = "device_setup" },
+ new() { ["softwareName"] = "Office", ["phase"] = "device_setup" },
+ }
+ };
+
+ Assert.False(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_true_when_native_report_has_user_enrollment_finding()
+ {
+ var report = new Dictionary
+ {
+ ["findings"] = new List>
+ {
+ new() { ["softwareName"] = "Edge", ["phase"] = "device_setup" },
+ new() { ["softwareName"] = "Office", ["phase"] = "user_enrollment" },
+ }
+ };
+
+ Assert.True(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_true_for_jarray_round_tripped_report()
+ {
+ // Simulates the shape we get back from the VulnerabilityReports table after a
+ // Newtonsoft round-trip of a stored Part-2 report.
+ var jarr = new JArray
+ {
+ JObject.FromObject(new { softwareName = "Edge", phase = "device_setup" }),
+ JObject.FromObject(new { softwareName = "Office", phase = "user_enrollment" }),
+ };
+ var report = new Dictionary { ["findings"] = jarr };
+
+ Assert.True(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_false_for_jarray_with_only_device_setup()
+ {
+ var jarr = new JArray
+ {
+ JObject.FromObject(new { softwareName = "Edge", phase = "device_setup" }),
+ };
+ var report = new Dictionary { ["findings"] = jarr };
+
+ Assert.False(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_returns_false_for_findings_without_phase_tag()
+ {
+ // Single-phase (non-WG) reports have no phase tag at all.
+ var report = new Dictionary
+ {
+ ["findings"] = new List>
+ {
+ new() { ["softwareName"] = "Edge" }, // no phase
+ }
+ };
+
+ Assert.False(VulnerabilityCorrelateHandler.ContainsUserEnrollmentFindings(report));
+ }
+
+ [Fact]
+ public void ContainsUserEnrollmentFindings_handles_loosely_typed_object_list()
+ {
+ // Path 3: List