mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+175
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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 ?? "?"}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user