From d4e67cdbb5f0da43fc49c9ab4beb0f2c1b087201 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 23 Oct 2026 00:44:16 +0200 Subject: [PATCH] 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 --- .../ConfigMgrClientDetectorTests.cs | 175 ++++++++++++++++++ .../SystemSignals/ConfigMgrClientDetector.cs | 105 +++++++++++ .../SystemSignals/ConfigMgrClientProbe.cs | 146 +++++++++++++++ .../Orchestration/DefaultComponentFactory.cs | 9 + .../Hosts/ConfigMgrClientHost.cs | 90 +++++++++ .../AutopilotMonitor.Shared/Constants.cs | 2 +- .../app/privacy/page.tsx | 4 +- .../sessions/components/SessionInfoCard.tsx | 1 - .../sessions/hooks/useSessionDerivedData.ts | 5 +- 9 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs new file mode 100644 index 00000000..cfcf6150 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ConfigMgrClientDetectorTests.cs @@ -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 +{ + /// The V1 confidence score of . + 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); + } + } + + /// + /// Emission surface of via the real + /// . Static probe override → serial collection. + /// + [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 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>(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>(events[0].TypedPayload); + Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder + var latest = Assert.IsAssignableFrom>(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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs new file mode 100644 index 00000000..5aa350e3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientDetector.cs @@ -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 +{ + /// + /// The V1 ConfigMgr client check, run on every poll: emits configmgr_client_detected + /// 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 fingerprint keeps a restart into the same state + /// silent. Observational only: emission goes through . + /// + internal sealed class ConfigMgrClientDetector + { + internal const string SourceLabel = "ConfigMgrClientDetector"; + + /// Emissions per agent run; a flapping service must not flood the timeline. + 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; + } + + /// Host timer callback and test seam — one probe round. + 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 + { + ["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 ?? "?"})."); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs new file mode 100644 index 00000000..aa203b26 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ConfigMgrClientProbe.cs @@ -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 +{ + /// + /// Reads the Configuration Manager client's footprint the way the V1 agent did: the + /// CCM key, the version from CCMSetup, the assigned site code, the + /// %windir%\CCM folder and the CcmExec service. Fail-soft: any failure yields + /// . The service is queried only when its key + /// exists, so devices without the client never throw on every poll. + /// + 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"; + + /// Service state when CcmExec is not installed (the V1 payload value). + internal const string ServiceNotFound = "not_found"; + + /// Test seam; use to guarantee cleanup. + internal static Func? 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(); + + /// Sets for the scope and restores the previous value on Dispose. + internal sealed class ScopedOverride : IDisposable + { + private readonly Func? _previous; + private int _disposed; + + public ScopedOverride(Func probe) + { + _previous = TestOverride; + TestOverride = probe; + } + + public void Dispose() + { + if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return; + TestOverride = _previous; + } + } + } + + /// One read of the client's footprint, with the V1 confidence score. + 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; } + + /// name, or . + public string ServiceState { get; } + + public string? Version { get; } + public string? SiteCode { get; } + + /// + /// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the + /// detection from 50 on. + /// + 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; + + /// Everything the event reports; a change re-emits. + public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}"; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 3a309831..e1afe325 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -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 diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs new file mode 100644 index 00000000..9a979c80 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ConfigMgrClientHost.cs @@ -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 +{ + /// + /// Drives the : 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. + /// + 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); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 41fcc72d..a9c3fff2 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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"; diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 64ccc969..aa0f101b 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -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() {
  • Device identity — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into
  • Enrollment progress — phases, ESP stages, application and script results, policy activity, Windows updates installed during the enrollment, reboots, timings, and failure codes
  • -
  • Device context — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request
  • +
  • Device context — 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
  • Approximate location — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx index cca1215e..ac89180b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx @@ -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). )} diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts index 9b381813..6b2d26c3 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts @@ -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;