ConfigMgr client detection is back in the V2 agent (D-382)

The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.

- Session Info shows the latest detection; the V1 sentence about disturbed
  event sequences is gone.
- Privacy page: device context names the Configuration Manager client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-23 00:44:16 +02:00
parent e4d3e8ed30
commit d4e67cdbb5
9 changed files with 531 additions and 6 deletions
@@ -0,0 +1,175 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
public sealed class ConfigMgrClientScoreTests
{
[Fact]
public void Nothing_found_scores_zero_and_is_not_detected()
{
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
}
[Fact]
public void A_registered_running_client_scores_100()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
Assert.Equal(100, snapshot.ConfidenceScore);
}
[Fact]
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
{
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
Assert.True(snapshot.Detected);
Assert.Equal(10, snapshot.ConfidenceScore);
}
[Fact]
public void A_stopped_service_counts_without_the_running_bonus()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
Assert.Equal(80, snapshot.ConfidenceScore);
}
}
/// <summary>
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
/// </summary>
[Collection("SerialThreading")]
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
return (sut, sink);
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == "configmgr_client_detected").ToList();
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
[Fact]
public void No_client_never_emits()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Empty(Detected(sink));
}
[Fact]
public void First_detection_emits_the_v1_payload()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
var (sut, sink) = BuildDetector();
sut.Check();
var evt = Assert.Single(Detected(sink));
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(true, data["ccmRegistryFound"]);
Assert.Equal(true, data["ccmDirectoryFound"]);
Assert.Equal("Running", data["ccmServiceState"]);
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
Assert.Equal("ABC", data["siteCode"]);
Assert.Equal(100, data["confidenceScore"]);
Assert.Equal("startup", data["detectedAt"]);
}
[Fact]
public void An_unchanged_state_emits_once_a_changed_state_again()
{
var current = Client("Stopped", siteCode: null);
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Single(Detected(sink));
current = Client("Running");
sut.Check();
var events = Detected(sink);
Assert.Equal(2, events.Count);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
Assert.Equal("Running", latest["ccmServiceState"]);
Assert.Equal("poll", latest["detectedAt"]);
}
[Fact]
public void A_restart_into_the_same_state_stays_silent()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var current = Client("Running");
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run1.Check();
Assert.Single(Detected(sink1));
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run2.Check();
Assert.Empty(Detected(sink2));
current = Client("Stopped");
run2.Check();
Assert.Single(Detected(sink2));
}
[Fact]
public void Emissions_are_capped_per_run()
{
var running = true;
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
var (sut, sink) = BuildDetector();
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
{
sut.Check();
running = !running;
}
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
}
}
}
@@ -0,0 +1,105 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
/// with the V1 payload when the client is first seen, and again when what it reports changes
/// (the service starts, a site code is assigned) — the client often installs after the agent
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
/// </summary>
internal sealed class ConfigMgrClientDetector
{
internal const string SourceLabel = "ConfigMgrClientDetector";
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
internal const int MaxEmissionsPerRun = 10;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly IClock _clock;
private readonly StartupEventGate? _startupGate;
private string? _lastFingerprint;
private int _emissions;
private bool _checkedOnce;
public ConfigMgrClientDetector(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
IClock clock,
StartupEventGate? startupGate = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger;
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_startupGate = startupGate;
}
/// <summary>Host timer callback and test seam — one probe round.</summary>
internal void Check()
{
var trigger = _checkedOnce ? "poll" : "startup";
_checkedOnce = true;
var snapshot = ConfigMgrClientProbe.Read(_logger);
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
return;
_lastFingerprint = snapshot.Fingerprint;
if (_startupGate != null && !_startupGate.ShouldEmit(
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
{
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
return;
}
_emissions++;
var score = snapshot.ConfidenceScore;
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = _clock.UtcNow,
EventType = Constants.EventTypes.ConfigMgrClientDetected,
Severity = EventSeverity.Info,
Source = SourceLabel,
Phase = EnrollmentPhase.Unknown,
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
Data = new Dictionary<string, object>
{
["ccmRegistryFound"] = snapshot.RegistryFound,
["ccmDirectoryFound"] = snapshot.DirectoryFound,
["ccmServiceState"] = snapshot.ServiceState,
["ccmVersion"] = snapshot.Version ?? "unknown",
["siteCode"] = snapshot.SiteCode ?? "unknown",
["confidenceScore"] = score,
["detectedAt"] = trigger,
}
});
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
_logger?.Info(
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
}
}
}
@@ -0,0 +1,146 @@
#nullable enable
using System;
using System.IO;
using System.ServiceProcess;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
/// exists, so devices without the client never throw on every poll.
/// </summary>
internal static class ConfigMgrClientProbe
{
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
internal const string CcmExecServiceName = "CcmExec";
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
internal const string ServiceNotFound = "not_found";
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
{
var probe = TestOverride;
if (probe != null) return probe();
try
{
// The agent is AnyCPU; the client writes the 64-bit view.
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
bool registryFound;
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
registryFound = ccm != null;
string? version;
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
string? siteCode;
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
bool serviceInstalled;
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
serviceInstalled = service != null;
var directoryFound = Directory.Exists(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
return new ConfigMgrClientSnapshot(
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
}
catch (Exception ex)
{
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
return ConfigMgrClientSnapshot.Empty;
}
}
private static string ReadServiceState()
{
try
{
using (var controller = new ServiceController(CcmExecServiceName))
return controller.Status.ToString();
}
catch (InvalidOperationException)
{
return ServiceNotFound; // removed between the key read and the query
}
}
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
internal sealed class ScopedOverride : IDisposable
{
private readonly Func<ConfigMgrClientSnapshot>? _previous;
private int _disposed;
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
{
_previous = TestOverride;
TestOverride = probe;
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
TestOverride = _previous;
}
}
}
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
internal sealed class ConfigMgrClientSnapshot
{
public static readonly ConfigMgrClientSnapshot Empty =
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
{
RegistryFound = registryFound;
DirectoryFound = directoryFound;
ServiceState = serviceState;
Version = version;
SiteCode = siteCode;
}
public bool RegistryFound { get; }
public bool DirectoryFound { get; }
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
public string ServiceState { get; }
public string? Version { get; }
public string? SiteCode { get; }
/// <summary>
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
/// detection from 50 on.
/// </summary>
public int ConfidenceScore =>
(DirectoryFound ? 10 : 0)
+ (RegistryFound ? 20 : 0)
+ (Version != null ? 15 : 0)
+ (SiteCode != null ? 15 : 0)
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
public bool Detected => ConfidenceScore > 0;
/// <summary>Everything the event reports; a change re-emits.</summary>
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
}
}
@@ -517,6 +517,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
startupGate: _startupEventGate));
}
// ConfigMgr client presence (the V1 check) — always-on, no config gate, on every flow.
hosts.Add(new ConfigMgrClientHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
startupGate: _startupEventGate));
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
@@ -0,0 +1,90 @@
#nullable enable
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Drives the <see cref="ConfigMgrClientDetector"/>: a first check shortly after start (the
/// client may already be installed) and one per minute after that (it may arrive later, e.g.
/// as a Win32 app). Always-on, no config gate — like the V1 check it replaces.
/// </summary>
internal sealed class ConfigMgrClientHost : ICollectorHost
{
public string Name => "ConfigMgrClientDetector";
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
private readonly ConfigMgrClientDetector _detector;
private readonly AgentLogger _logger;
private Timer? _tickTimer;
private int _checking;
private int _disposed;
public ConfigMgrClientHost(
string sessionId,
string tenantId,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
StartupEventGate? startupGate)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger;
_detector = new ConfigMgrClientDetector(
sessionId: sessionId,
tenantId: tenantId,
post: new InformationalEventPost(ingress, clock),
logger: logger,
clock: clock,
startupGate: startupGate);
}
public void Start()
{
_tickTimer = new Timer(_ => SafeCheck(), state: null, dueTime: FirstCheckDelay, period: PollInterval);
_logger.Info($"ConfigMgrClientHost: started (check every {PollInterval.TotalSeconds}s).");
}
public void Stop()
{
try
{
_tickTimer?.Dispose();
_tickTimer = null;
}
catch (Exception ex) { _logger.Warning($"ConfigMgrClientHost: timer dispose failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
}
private void SafeCheck()
{
// A slow service query must not stack a second check on top of the first.
if (Interlocked.Exchange(ref _checking, 1) == 1) return;
try
{
_detector.Check();
}
catch (Exception ex)
{
_logger.Error("ConfigMgrClientHost: check failed.", ex);
}
finally
{
Interlocked.Exchange(ref _checking, 0);
}
}
}
}
@@ -742,6 +742,7 @@ namespace AutopilotMonitor.Shared
public const string SessionParkedWithoutDeadline = "session_parked_without_deadline"; // One-shot Warning tripwire (liveness plan PR1): a non-terminal session entered the post-AccountSetup dead-end zone (ESP final exit or advisory failure) with NO resolution-capable deadline armed. After the 2026-06-12 dead-end fixes this must never fire in production — every occurrence is a bug report for an unknown parking variant.
public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal.
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3} while device preparation is still open). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
public const string ConfigMgrClientDetected = "configmgr_client_detected"; // Info when the ConfigMgr client is first seen and again when its reported state changes (CCM key, CCMSetup version, assigned site code, %windir%\CCM, CcmExec state; V1 confidence score), capped per run. The V1 check, restored in V2 (D-382).
public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off).
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
@@ -1046,7 +1047,6 @@ namespace AutopilotMonitor.Shared
// search/catalog stay complete for historical V1 session analysis. V1 emit
// sites are intentionally NOT refactored to use these (V1 is going away).
// ---------------------------------------------------------------------
public const string ConfigMgrClientDetected = "configmgr_client_detected";
public const string EnrollmentTypeMismatch = "enrollment_type_mismatch";
public const string DecisionProcessCompletion = "decision_process_completion";
public const string WhiteGloveClassification = "whiteglove_classification";
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SiteFooter } from "../../components/SiteFooter";
import { DOCS_URL } from "@/lib/config";
const LAST_UPDATED = "20 October 2026";
const LAST_UPDATED = "23 October 2026";
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
@@ -71,7 +71,7 @@ export default function PrivacyPage() {
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
<li><strong>Enrollment progress</strong> — phases, ESP stages, application and script results, policy activity, Windows updates installed during the enrollment, reboots, timings, and failure codes</li>
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request</li>
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, whether the Configuration Manager client is installed (service state, version, site code), plus whatever your own gather rules request</li>
<li><strong>Approximate location</strong> — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant</li>
</ul>
@@ -146,7 +146,6 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
{configMgrDetected.ccmServiceState && configMgrDetected.ccmServiceState !== "not_found" && <> — {configMgrDetected.ccmServiceState}</>}
{configMgrDetected.ccmVersion && configMgrDetected.ccmVersion !== "unknown" && <>, v{configMgrDetected.ccmVersion}</>}
{configMgrDetected.siteCode && configMgrDetected.siteCode !== "unknown" && <>, site {configMgrDetected.siteCode}</>}.
{" "}The SCCM agent may trigger reboots during ESP that disrupt the enrollment flow and cause unexpected event sequences (e.g., device events appearing in the account phase, AccountSetup never starting).
</span>
</div>
)}
@@ -109,11 +109,12 @@ export function useSessionDerivedData(
};
}, [events]);
// Extract ConfigMgr co-management detection (if present)
// Latest ConfigMgr client detection — the agent reports again when the client's state changes.
// Only show badge when confidence >= 50 (directory-only is too weak).
// Default to 100 for backward compat with old agent events that lack confidenceScore.
const configMgrDetected = useMemo(() => {
const evt = events.find(e => e.eventType === "configmgr_client_detected");
const detections = events.filter(e => e.eventType === "configmgr_client_detected");
const evt = detections[detections.length - 1];
if (!evt?.data) return null;
const confidence = (evt.data.confidenceScore as number) ?? 100;
if (confidence < 50) return null;