diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs
new file mode 100644
index 00000000..a5f3225f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs
@@ -0,0 +1,701 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Management;
+using System.Net.NetworkInformation;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
+{
+ ///
+ /// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join),
+ /// ESP configuration, WiFi signal, and helper methods.
+ ///
+ public partial class DeviceInfoCollector
+ {
+ private void CollectNetworkAndDnsConfiguration()
+ {
+ try
+ {
+ var adapters = new List>();
+ var dnsServers = new List>();
+
+ using (var searcher = new ManagementObjectSearcher(
+ "SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
+ "FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
+ {
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ // Network adapter info
+ var adapter = new Dictionary
+ {
+ { "description", obj["Description"]?.ToString() },
+ { "macAddress", obj["MACAddress"]?.ToString() },
+ { "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
+ { "dhcpServer", obj["DHCPServer"]?.ToString() }
+ };
+
+ var ipAddresses = obj["IPAddress"] as string[];
+ if (ipAddresses != null)
+ adapter["ipAddresses"] = string.Join(", ", ipAddresses);
+
+ var subnets = obj["IPSubnet"] as string[];
+ if (subnets != null)
+ adapter["subnets"] = string.Join(", ", subnets);
+
+ var gateways = obj["DefaultIPGateway"] as string[];
+ if (gateways != null)
+ adapter["gateways"] = string.Join(", ", gateways);
+
+ adapters.Add(adapter);
+
+ // DNS info (extracted from the same result set)
+ var servers = obj["DNSServerSearchOrder"] as string[];
+ if (servers != null && servers.Length > 0)
+ {
+ dnsServers.Add(new Dictionary
+ {
+ { "adapter", obj["Description"]?.ToString() },
+ { "servers", string.Join(", ", servers) }
+ });
+ }
+ }
+ }
+
+ EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
+ new Dictionary
+ {
+ { "adapterCount", adapters.Count },
+ { "adapters", adapters }
+ });
+
+ EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
+ new Dictionary
+ {
+ { "dnsEntries", dnsServers }
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
+ }
+ }
+
+ private void CollectProxyConfiguration()
+ {
+ try
+ {
+ var data = new Dictionary();
+
+ using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
+ {
+ if (key != null)
+ {
+ data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
+ data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
+ data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
+ data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
+ }
+ }
+
+ // Also check WinHTTP proxy
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
+ {
+ data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
+ }
+
+ var proxyType = "Direct";
+ if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
+ proxyType = "Proxy";
+ else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
+ proxyType = "PAC";
+
+ data["proxyType"] = proxyType;
+
+ EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
+ /// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
+ /// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
+ /// not the actual live enrollment state.
+ ///
+ private (string enrollmentType, bool isHybridJoin) CollectAutopilotProfile()
+ {
+ var detectedType = "v1";
+ var isHybridJoin = false;
+
+ try
+ {
+ var data = new Dictionary();
+
+ // Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
+ {
+ if (key != null)
+ {
+ // The registry key contains individual values that together form the Autopilot profile
+ // Read all available values and add them to data dictionary
+ foreach (var valueName in key.GetValueNames())
+ {
+ var value = key.GetValue(valueName);
+ if (value != null)
+ {
+ // Store with original casing from registry
+ var valueAsString = value.ToString();
+ if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
+ {
+ valueAsString = TryFormatJson(valueAsString);
+ }
+
+ data[valueName] = valueAsString;
+ }
+ }
+
+ // Extract key values for enrollment type detection
+ var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
+ ? data["CloudAssignedDeviceRegistration"]?.ToString()
+ : null;
+ var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
+ ? data["CloudAssignedEspEnabled"]?.ToString()
+ : null;
+
+ // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
+ if (deviceReg == "2" || espEnabled == "0")
+ detectedType = "v2";
+ else
+ detectedType = "v1";
+
+ // --- Interpret AutopilotMode (profile capability, not live state) ---
+ // 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
+ var autopilotModeStr = data.ContainsKey("AutopilotMode")
+ ? data["AutopilotMode"]?.ToString()
+ : null;
+ if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
+ {
+ string modeLabel;
+ switch (autopilotMode)
+ {
+ case 0: modeLabel = "User Driven"; break;
+ case 1: modeLabel = "Self Deploying"; break;
+ case 2: modeLabel = "Pre-Provisioning (White Glove)"; break;
+ default: modeLabel = $"Unknown ({autopilotMode})"; break;
+ }
+ data["autopilotModeLabel"] = modeLabel;
+ _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
+ }
+
+ // --- Interpret CloudAssignedDomainJoinMethod ---
+ // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
+ var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
+ ? data["CloudAssignedDomainJoinMethod"]?.ToString()
+ : null;
+ if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
+ {
+ string joinLabel;
+ switch (domainJoinMethod)
+ {
+ case 0: joinLabel = "Entra Join"; break;
+ case 1: joinLabel = "Hybrid Azure AD Join"; break;
+ default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
+ }
+ data["domainJoinMethodLabel"] = joinLabel;
+
+ // Hybrid Join is reliably indicated by DomainJoinMethod == 1
+ isHybridJoin = domainJoinMethod == 1;
+ }
+
+ data["isHybridJoin"] = isHybridJoin;
+
+ if (isHybridJoin)
+ {
+ _logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
+ }
+
+ _logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
+ }
+ else
+ {
+ _logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
+ }
+ }
+
+ // Include enrollment type in autopilot_profile event data
+ data["enrollmentType"] = detectedType;
+
+ EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
+
+ // Emit dedicated enrollment_type_detected event for easy filtering
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_type_detected",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = detectedType == "v2"
+ ? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
+ : "Enrollment type: Autopilot v1 (Classic ESP)",
+ Data = new Dictionary { { "enrollmentType", detectedType } }
+ });
+
+ _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
+ }
+
+ return (detectedType, isHybridJoin);
+ }
+
+ private void CollectSecureBootStatus()
+ {
+ try
+ {
+ var data = new Dictionary();
+
+ using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
+ {
+ if (key != null)
+ {
+ var value = key.GetValue("UEFISecureBootEnabled");
+ data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
+ }
+ else
+ {
+ data["uefiSecureBootEnabled"] = false;
+ data["note"] = "SecureBoot registry key not found";
+ }
+ }
+
+ EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", data);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
+ }
+ }
+
+ private void CollectBitLockerStatus()
+ {
+ try
+ {
+ var data = new Dictionary();
+ var volumes = new List>();
+
+ using (var searcher = new ManagementObjectSearcher(
+ new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
+ new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
+ {
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ volumes.Add(new Dictionary
+ {
+ { "driveLetter", obj["DriveLetter"]?.ToString() },
+ { "protectionStatus", obj["ProtectionStatus"]?.ToString() },
+ { "conversionStatus", obj["ConversionStatus"]?.ToString() },
+ { "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
+ });
+ }
+ }
+
+ data["volumes"] = volumes;
+ data["systemDriveProtected"] = volumes.Any(v =>
+ v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
+
+ EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
+ }
+ }
+
+ private void CollectTpmStatus()
+ {
+ try
+ {
+ var data = new Dictionary();
+
+ using (var searcher = new ManagementObjectSearcher(
+ new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
+ new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
+ {
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
+ data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
+ data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
+ data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
+ data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
+
+ // SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
+ var specVersion = obj["SpecVersion"]?.ToString();
+ if (!string.IsNullOrEmpty(specVersion))
+ {
+ var parts = specVersion.Split(',');
+ data["specVersion"] = parts[0].Trim();
+ }
+ }
+ }
+
+ if (data.Count > 0)
+ {
+ EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
+ }
+ else
+ {
+ data["available"] = false;
+ EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
+ }
+ }
+
+ private void CollectAadJoinStatus()
+ {
+ try
+ {
+ var data = new Dictionary();
+
+ using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"))
+ {
+ if (joinInfoKey != null)
+ {
+ var subKeyNames = joinInfoKey.GetSubKeyNames();
+ if (subKeyNames.Length > 0)
+ {
+ using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0]))
+ {
+ if (subKey != null)
+ {
+ data["tenantId"] = subKey.GetValue("TenantId")?.ToString();
+ data["userEmail"] = subKey.GetValue("UserEmail")?.ToString();
+ data["joinType"] = "Azure AD Joined";
+ data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint
+ }
+ }
+ }
+ else
+ {
+ data["joinType"] = "Not Joined";
+ }
+ }
+ else
+ {
+ data["joinType"] = "Not Joined";
+ }
+ }
+
+ object joinTypeValue;
+ var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
+ EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Reads ESP skip configuration from the enrollment FirstSync registry key.
+ /// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
+ /// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
+ /// We use defensive interpretation: non-null AND non-zero = skip.
+ ///
+ private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
+ {
+ bool? skipUser = null;
+ bool? skipDevice = null;
+ string enrollmentGuid = null;
+
+ try
+ {
+ const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
+
+ using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
+ {
+ if (enrollmentsKey == null)
+ {
+ _logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
+ return (null, null);
+ }
+
+ // Find the MDM enrollment entry (EnrollmentType == 6)
+ foreach (var guid in enrollmentsKey.GetSubKeyNames())
+ {
+ using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
+ {
+ if (enrollmentKey == null) continue;
+
+ var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
+ if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
+ continue;
+
+ enrollmentGuid = guid;
+
+ // Read FirstSync subkey
+ using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
+ {
+ if (firstSyncKey == null)
+ {
+ _logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
+ break;
+ }
+
+ var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
+ if (rawSkipUser != null)
+ skipUser = Convert.ToInt32(rawSkipUser) != 0;
+
+ var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
+ if (rawSkipDevice != null)
+ skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
+ }
+
+ break; // Found the MDM enrollment entry
+ }
+ }
+ }
+
+ var data = new Dictionary
+ {
+ { "source", "registry_firstsync" }
+ };
+ if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
+ if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
+ if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
+
+ var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
+ _logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
+ EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
+ }
+
+ return (skipUser, skipDevice);
+ }
+
+ ///
+ /// Collects active network interface details (name, type, link speed, MAC) using
+ /// System.Net.NetworkInformation — instant, no process spawn.
+ /// If the active interface is WiFi, fires off a separate async task to collect
+ /// WiFi signal info via netsh (fire-and-forget, never blocks).
+ ///
+ private void CollectActiveNetworkInterfaceInfo()
+ {
+ try
+ {
+ var activeNic = FindActiveNetworkInterface();
+
+ if (activeNic == null)
+ {
+ EmitDeviceInfoEvent("network_interface_info", "No active network interface found",
+ new Dictionary { { "status", "no_active_interface" } });
+ return;
+ }
+
+ var data = new Dictionary
+ {
+ { "adapterName", activeNic.Name },
+ { "adapterDescription", activeNic.Description },
+ { "macAddress", activeNic.GetPhysicalAddress().ToString() },
+ { "interfaceType", activeNic.NetworkInterfaceType.ToString() },
+ { "linkSpeedMbps", activeNic.Speed / 1_000_000 },
+ { "interfaceId", activeNic.Id }
+ };
+
+ var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
+ data["connectionType"] = isWifi ? "WiFi" : "Ethernet";
+
+ var gateways = activeNic.GetIPProperties().GatewayAddresses;
+ if (gateways.Count > 0)
+ {
+ var gwList = new List();
+ foreach (var gw in gateways)
+ gwList.Add(gw.Address.ToString());
+ data["gateways"] = string.Join(", ", gwList);
+ }
+
+ var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)";
+ EmitDeviceInfoEvent("network_interface_info", message, data);
+
+ // Fire-and-forget WiFi signal collection — separate event, never blocks
+ if (isWifi)
+ {
+ Task.Run(() => CollectWiFiSignalInfo());
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
+ ///
+ private static NetworkInterface FindActiveNetworkInterface()
+ {
+ try
+ {
+ var interfaces = NetworkInterface.GetAllNetworkInterfaces();
+ foreach (var nic in interfaces)
+ {
+ if (nic.OperationalStatus != OperationalStatus.Up)
+ continue;
+ if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
+ nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
+ continue;
+
+ var ipProps = nic.GetIPProperties();
+ foreach (var gw in ipProps.GatewayAddresses)
+ {
+ if (gw.Address.ToString() != "0.0.0.0")
+ return nic;
+ }
+ }
+ }
+ catch
+ {
+ // Caller handles null
+ }
+ return null;
+ }
+
+ ///
+ /// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
+ /// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
+ /// never blocks other collection. VMs without WiFi service simply get no event.
+ ///
+ private void CollectWiFiSignalInfo()
+ {
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "netsh.exe",
+ Arguments = "wlan show interfaces",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ string output;
+ using (var process = Process.Start(psi))
+ {
+ output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit(5000);
+ }
+
+ if (string.IsNullOrEmpty(output))
+ return;
+
+ var data = new Dictionary();
+
+ foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ var trimmed = line.Trim();
+ var colonIndex = trimmed.IndexOf(':');
+ if (colonIndex < 0) continue;
+
+ var key = trimmed.Substring(0, colonIndex).Trim();
+ var value = trimmed.Substring(colonIndex + 1).Trim();
+
+ if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
+ !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
+ {
+ data["wifiSsid"] = value;
+ }
+ else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
+ {
+ if (int.TryParse(value.TrimEnd('%'), out var signal))
+ data["wifiSignalPercent"] = signal;
+ }
+ else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
+ {
+ data["wifiRadioType"] = value;
+ }
+ else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
+ {
+ if (int.TryParse(value, out var channel))
+ data["wifiChannel"] = channel;
+ }
+ }
+
+ if (data.Count > 0)
+ {
+ var message = data.ContainsKey("wifiSsid")
+ ? $"WiFi: {data["wifiSsid"]}"
+ : "WiFi signal info";
+ if (data.ContainsKey("wifiSignalPercent"))
+ message += $", Signal: {data["wifiSignalPercent"]}%";
+ if (data.ContainsKey("wifiRadioType"))
+ message += $" ({data["wifiRadioType"]})";
+
+ EmitDeviceInfoEvent("wifi_signal_info", message, data);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"WiFi signal info collection failed: {ex.Message}");
+ }
+ }
+
+ private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data)
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = data
+ });
+ }
+
+ private static string TryFormatJson(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return input ?? string.Empty;
+
+ try
+ {
+ using (var doc = JsonDocument.Parse(input))
+ {
+ return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+ }
+ }
+ catch
+ {
+ return input;
+ }
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs
index 415c18d7..52283a7f 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs
@@ -18,7 +18,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status,
/// bitlocker_status, aad_join_status) via the provided emitEvent action.
///
- public class DeviceInfoCollector
+ public partial class DeviceInfoCollector
{
private readonly string _sessionId;
private readonly string _tenantId;
@@ -206,683 +206,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// consolidated to halve the WMI round-trips (each creates a new COM connection).
/// Emits two events (network_adapters, dns_configuration) for backward compatibility.
///
- private void CollectNetworkAndDnsConfiguration()
- {
- try
- {
- var adapters = new List>();
- var dnsServers = new List>();
-
- using (var searcher = new ManagementObjectSearcher(
- "SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
- "FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
- {
- foreach (ManagementObject obj in searcher.Get())
- {
- // Network adapter info
- var adapter = new Dictionary
- {
- { "description", obj["Description"]?.ToString() },
- { "macAddress", obj["MACAddress"]?.ToString() },
- { "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
- { "dhcpServer", obj["DHCPServer"]?.ToString() }
- };
-
- var ipAddresses = obj["IPAddress"] as string[];
- if (ipAddresses != null)
- adapter["ipAddresses"] = string.Join(", ", ipAddresses);
-
- var subnets = obj["IPSubnet"] as string[];
- if (subnets != null)
- adapter["subnets"] = string.Join(", ", subnets);
-
- var gateways = obj["DefaultIPGateway"] as string[];
- if (gateways != null)
- adapter["gateways"] = string.Join(", ", gateways);
-
- adapters.Add(adapter);
-
- // DNS info (extracted from the same result set)
- var servers = obj["DNSServerSearchOrder"] as string[];
- if (servers != null && servers.Length > 0)
- {
- dnsServers.Add(new Dictionary
- {
- { "adapter", obj["Description"]?.ToString() },
- { "servers", string.Join(", ", servers) }
- });
- }
- }
- }
-
- EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
- new Dictionary
- {
- { "adapterCount", adapters.Count },
- { "adapters", adapters }
- });
-
- EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
- new Dictionary
- {
- { "dnsEntries", dnsServers }
- });
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
- }
- }
-
- private void CollectProxyConfiguration()
- {
- try
- {
- var data = new Dictionary();
-
- using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
- {
- if (key != null)
- {
- data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
- data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
- data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
- data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
- }
- }
-
- // Also check WinHTTP proxy
- using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
- {
- data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
- }
-
- var proxyType = "Direct";
- if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
- proxyType = "Proxy";
- else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
- proxyType = "PAC";
-
- data["proxyType"] = proxyType;
-
- EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
- }
- }
-
- ///
- /// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
- /// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
- /// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
- /// not the actual live enrollment state.
- ///
- private (string enrollmentType, bool isHybridJoin) CollectAutopilotProfile()
- {
- var detectedType = "v1";
- var isHybridJoin = false;
-
- try
- {
- var data = new Dictionary();
-
- // Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
- using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
- {
- if (key != null)
- {
- // The registry key contains individual values that together form the Autopilot profile
- // Read all available values and add them to data dictionary
- foreach (var valueName in key.GetValueNames())
- {
- var value = key.GetValue(valueName);
- if (value != null)
- {
- // Store with original casing from registry
- var valueAsString = value.ToString();
- if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
- {
- valueAsString = TryFormatJson(valueAsString);
- }
-
- data[valueName] = valueAsString;
- }
- }
-
- // Extract key values for enrollment type detection
- var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
- ? data["CloudAssignedDeviceRegistration"]?.ToString()
- : null;
- var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
- ? data["CloudAssignedEspEnabled"]?.ToString()
- : null;
-
- // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
- if (deviceReg == "2" || espEnabled == "0")
- detectedType = "v2";
- else
- detectedType = "v1";
-
- // --- Interpret AutopilotMode (profile capability, not live state) ---
- // 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
- var autopilotModeStr = data.ContainsKey("AutopilotMode")
- ? data["AutopilotMode"]?.ToString()
- : null;
- if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
- {
- string modeLabel;
- switch (autopilotMode)
- {
- case 0: modeLabel = "User Driven"; break;
- case 1: modeLabel = "Self Deploying"; break;
- case 2: modeLabel = "Pre-Provisioning (White Glove)"; break;
- default: modeLabel = $"Unknown ({autopilotMode})"; break;
- }
- data["autopilotModeLabel"] = modeLabel;
- _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
- }
-
- // --- Interpret CloudAssignedDomainJoinMethod ---
- // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
- var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
- ? data["CloudAssignedDomainJoinMethod"]?.ToString()
- : null;
- if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
- {
- string joinLabel;
- switch (domainJoinMethod)
- {
- case 0: joinLabel = "Entra Join"; break;
- case 1: joinLabel = "Hybrid Azure AD Join"; break;
- default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
- }
- data["domainJoinMethodLabel"] = joinLabel;
-
- // Hybrid Join is reliably indicated by DomainJoinMethod == 1
- isHybridJoin = domainJoinMethod == 1;
- }
-
- data["isHybridJoin"] = isHybridJoin;
-
- if (isHybridJoin)
- {
- _logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
- }
-
- _logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
- }
- else
- {
- _logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
- }
- }
-
- // Include enrollment type in autopilot_profile event data
- data["enrollmentType"] = detectedType;
-
- EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
-
- // Emit dedicated enrollment_type_detected event for easy filtering
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_type_detected",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = detectedType == "v2"
- ? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
- : "Enrollment type: Autopilot v1 (Classic ESP)",
- Data = new Dictionary { { "enrollmentType", detectedType } }
- });
-
- _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
- }
-
- return (detectedType, isHybridJoin);
- }
-
- private void CollectSecureBootStatus()
- {
- try
- {
- var data = new Dictionary();
-
- using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
- {
- if (key != null)
- {
- var value = key.GetValue("UEFISecureBootEnabled");
- data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
- }
- else
- {
- data["uefiSecureBootEnabled"] = false;
- data["note"] = "SecureBoot registry key not found";
- }
- }
-
- EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", data);
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
- }
- }
-
- private void CollectBitLockerStatus()
- {
- try
- {
- var data = new Dictionary();
- var volumes = new List>();
-
- using (var searcher = new ManagementObjectSearcher(
- new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
- new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
- {
- foreach (ManagementObject obj in searcher.Get())
- {
- volumes.Add(new Dictionary
- {
- { "driveLetter", obj["DriveLetter"]?.ToString() },
- { "protectionStatus", obj["ProtectionStatus"]?.ToString() },
- { "conversionStatus", obj["ConversionStatus"]?.ToString() },
- { "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
- });
- }
- }
-
- data["volumes"] = volumes;
- data["systemDriveProtected"] = volumes.Any(v =>
- v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
-
- EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
- }
- }
-
- private void CollectTpmStatus()
- {
- try
- {
- var data = new Dictionary();
-
- using (var searcher = new ManagementObjectSearcher(
- new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
- new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
- {
- foreach (ManagementObject obj in searcher.Get())
- {
- data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
- data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
- data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
- data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
- data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
-
- // SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
- var specVersion = obj["SpecVersion"]?.ToString();
- if (!string.IsNullOrEmpty(specVersion))
- {
- var parts = specVersion.Split(',');
- data["specVersion"] = parts[0].Trim();
- }
- }
- }
-
- if (data.Count > 0)
- {
- EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
- }
- else
- {
- data["available"] = false;
- EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
- }
- }
-
- private void CollectAadJoinStatus()
- {
- try
- {
- var data = new Dictionary();
-
- using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"))
- {
- if (joinInfoKey != null)
- {
- var subKeyNames = joinInfoKey.GetSubKeyNames();
- if (subKeyNames.Length > 0)
- {
- using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0]))
- {
- if (subKey != null)
- {
- data["tenantId"] = subKey.GetValue("TenantId")?.ToString();
- data["userEmail"] = subKey.GetValue("UserEmail")?.ToString();
- data["joinType"] = "Azure AD Joined";
- data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint
- }
- }
- }
- else
- {
- data["joinType"] = "Not Joined";
- }
- }
- else
- {
- data["joinType"] = "Not Joined";
- }
- }
-
- object joinTypeValue;
- var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
- EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
- }
- }
-
- ///
- /// Reads ESP skip configuration from the enrollment FirstSync registry key.
- /// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
- /// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
- /// We use defensive interpretation: non-null AND non-zero = skip.
- ///
- private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
- {
- bool? skipUser = null;
- bool? skipDevice = null;
- string enrollmentGuid = null;
-
- try
- {
- const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
-
- using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
- {
- if (enrollmentsKey == null)
- {
- _logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
- return (null, null);
- }
-
- // Find the MDM enrollment entry (EnrollmentType == 6)
- foreach (var guid in enrollmentsKey.GetSubKeyNames())
- {
- using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
- {
- if (enrollmentKey == null) continue;
-
- var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
- if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
- continue;
-
- enrollmentGuid = guid;
-
- // Read FirstSync subkey
- using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
- {
- if (firstSyncKey == null)
- {
- _logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
- break;
- }
-
- var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
- if (rawSkipUser != null)
- skipUser = Convert.ToInt32(rawSkipUser) != 0;
-
- var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
- if (rawSkipDevice != null)
- skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
- }
-
- break; // Found the MDM enrollment entry
- }
- }
- }
-
- var data = new Dictionary
- {
- { "source", "registry_firstsync" }
- };
- if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
- if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
- if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
-
- var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
- _logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
- EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
- }
-
- return (skipUser, skipDevice);
- }
-
- ///
- /// Collects active network interface details (name, type, link speed, MAC) using
- /// System.Net.NetworkInformation — instant, no process spawn.
- /// If the active interface is WiFi, fires off a separate async task to collect
- /// WiFi signal info via netsh (fire-and-forget, never blocks).
- ///
- private void CollectActiveNetworkInterfaceInfo()
- {
- try
- {
- var activeNic = FindActiveNetworkInterface();
-
- if (activeNic == null)
- {
- EmitDeviceInfoEvent("network_interface_info", "No active network interface found",
- new Dictionary { { "status", "no_active_interface" } });
- return;
- }
-
- var data = new Dictionary
- {
- { "adapterName", activeNic.Name },
- { "adapterDescription", activeNic.Description },
- { "macAddress", activeNic.GetPhysicalAddress().ToString() },
- { "interfaceType", activeNic.NetworkInterfaceType.ToString() },
- { "linkSpeedMbps", activeNic.Speed / 1_000_000 },
- { "interfaceId", activeNic.Id }
- };
-
- var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
- data["connectionType"] = isWifi ? "WiFi" : "Ethernet";
-
- var gateways = activeNic.GetIPProperties().GatewayAddresses;
- if (gateways.Count > 0)
- {
- var gwList = new List();
- foreach (var gw in gateways)
- gwList.Add(gw.Address.ToString());
- data["gateways"] = string.Join(", ", gwList);
- }
-
- var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)";
- EmitDeviceInfoEvent("network_interface_info", message, data);
-
- // Fire-and-forget WiFi signal collection — separate event, never blocks
- if (isWifi)
- {
- Task.Run(() => CollectWiFiSignalInfo());
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}");
- }
- }
-
- ///
- /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
- ///
- private static NetworkInterface FindActiveNetworkInterface()
- {
- try
- {
- var interfaces = NetworkInterface.GetAllNetworkInterfaces();
- foreach (var nic in interfaces)
- {
- if (nic.OperationalStatus != OperationalStatus.Up)
- continue;
- if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
- nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
- continue;
-
- var ipProps = nic.GetIPProperties();
- foreach (var gw in ipProps.GatewayAddresses)
- {
- if (gw.Address.ToString() != "0.0.0.0")
- return nic;
- }
- }
- }
- catch
- {
- // Caller handles null
- }
- return null;
- }
-
- ///
- /// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
- /// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
- /// never blocks other collection. VMs without WiFi service simply get no event.
- ///
- private void CollectWiFiSignalInfo()
- {
- try
- {
- var psi = new ProcessStartInfo
- {
- FileName = "netsh.exe",
- Arguments = "wlan show interfaces",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- string output;
- using (var process = Process.Start(psi))
- {
- output = process.StandardOutput.ReadToEnd();
- process.WaitForExit(5000);
- }
-
- if (string.IsNullOrEmpty(output))
- return;
-
- var data = new Dictionary();
-
- foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
- {
- var trimmed = line.Trim();
- var colonIndex = trimmed.IndexOf(':');
- if (colonIndex < 0) continue;
-
- var key = trimmed.Substring(0, colonIndex).Trim();
- var value = trimmed.Substring(colonIndex + 1).Trim();
-
- if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
- !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
- {
- data["wifiSsid"] = value;
- }
- else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
- {
- if (int.TryParse(value.TrimEnd('%'), out var signal))
- data["wifiSignalPercent"] = signal;
- }
- else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
- {
- data["wifiRadioType"] = value;
- }
- else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
- {
- if (int.TryParse(value, out var channel))
- data["wifiChannel"] = channel;
- }
- }
-
- if (data.Count > 0)
- {
- var message = data.ContainsKey("wifiSsid")
- ? $"WiFi: {data["wifiSsid"]}"
- : "WiFi signal info";
- if (data.ContainsKey("wifiSignalPercent"))
- message += $", Signal: {data["wifiSignalPercent"]}%";
- if (data.ContainsKey("wifiRadioType"))
- message += $" ({data["wifiRadioType"]})";
-
- EmitDeviceInfoEvent("wifi_signal_info", message, data);
- }
- }
- catch (Exception ex)
- {
- _logger.Debug($"WiFi signal info collection failed: {ex.Message}");
- }
- }
-
- private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data)
- {
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = eventType,
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = message,
- Data = data
- });
- }
-
- private static string TryFormatJson(string input)
- {
- if (string.IsNullOrWhiteSpace(input))
- return input ?? string.Empty;
-
- try
- {
- using (var doc = JsonDocument.Parse(input))
- {
- return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
- {
- WriteIndented = true
- });
- }
- }
- catch
- {
- return input;
- }
- }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs
new file mode 100644
index 00000000..3aa4a4e2
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs
@@ -0,0 +1,554 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Eventing.Reader;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
+{
+ ///
+ /// Partial: Hello policy detection, User Device Registration event handling, and Hello timers.
+ ///
+ public partial class EspAndHelloTracker
+ {
+ private void CheckHelloPolicy()
+ {
+ try
+ {
+ var (isEnabled, source) = DetectHelloPolicy();
+
+ lock (_stateLock)
+ {
+ // If policy was not configured before but is now, update state and emit event
+ if (!_isPolicyConfigured && isEnabled.HasValue)
+ {
+ _isPolicyConfigured = true;
+ _isHelloPolicyEnabled = isEnabled.Value;
+ var status = isEnabled.Value ? "enabled" : "disabled";
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "hello_policy_detected",
+ Severity = EventSeverity.Info,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Windows Hello for Business policy detected: {status} (via {source})",
+ Data = new Dictionary
+ {
+ { "helloEnabled", isEnabled.Value },
+ { "policySource", source }
+ }
+ });
+
+ _logger.Info($"WHfB policy detected: {status} (source: {source})");
+
+ // Stop periodic policy check - we found what we were looking for
+ if (_policyCheckTimer != null)
+ {
+ _policyCheckTimer.Dispose();
+ _policyCheckTimer = null;
+ _logger.Debug("Stopped periodic Hello policy check - policy has been detected");
+ }
+ }
+ else if (!_isPolicyConfigured && !isEnabled.HasValue)
+ {
+ // Policy still not found - this is normal during early enrollment (Debug level to keep UI clean)
+ _logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Error checking WHfB policy: {ex.Message}");
+ }
+ }
+
+ private (bool? isEnabled, string source) DetectHelloPolicy()
+ {
+ // 1. Check CSP/Intune-delivered policy (per-tenant paths)
+ // Policy can be device-scoped: {tenantId}\Device\Policies
+ // or user-scoped: {tenantId}\{userSid}\Policies
+ try
+ {
+ using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
+ {
+ if (baseCspKey != null)
+ {
+ foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
+ {
+ using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
+ {
+ if (tenantKey != null)
+ {
+ // Check all subkeys (Device or user SIDs like S-1-5-...)
+ foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
+ {
+ using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
+ {
+ if (policiesKey != null)
+ {
+ var value = policiesKey.GetValue("UsePassportForWork");
+ if (value != null)
+ {
+ var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
+ ? "device"
+ : "user";
+ return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ catch { }
+
+ // 2. Check GPO-delivered policy
+ try
+ {
+ using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
+ {
+ if (gpoKey != null)
+ {
+ var value = gpoKey.GetValue("Enabled");
+ if (value != null)
+ {
+ return (Convert.ToInt32(value) == 1, "GPO");
+ }
+ }
+ }
+ }
+ catch { }
+
+ return (null, null);
+ }
+
+ private void StartEventLogWatcher()
+ {
+ try
+ {
+ // Watch for specific WHfB-related event IDs
+ var query = new EventLogQuery(
+ EventLogChannel,
+ PathType.LogName,
+ "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"
+ );
+
+ _watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
+ _watcher.EventRecordWritten += OnEventRecordWritten;
+ _watcher.Enabled = true;
+
+ _logger.Info($"Started watching: {EventLogChannel}");
+ }
+ catch (EventLogNotFoundException)
+ {
+ _logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Failed to start Hello event log watcher", ex);
+ }
+ }
+
+ private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
+ {
+ if (e.EventRecord == null)
+ return;
+
+ try
+ {
+ var record = e.EventRecord;
+ var eventId = record.Id;
+ if (!TrackedEventIds.Contains(eventId))
+ return;
+
+ ProcessHelloEvent(
+ eventId,
+ (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
+ record.ProviderName ?? "",
+ isBackfill: false);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error processing Hello event record", ex);
+ }
+ }
+
+ private void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
+ {
+ string eventType;
+ EventSeverity severity;
+ string message;
+ bool shouldTriggerHelloCompleted = false;
+
+ switch (eventId)
+ {
+ case EventId_ProvisioningWillLaunch: // 358
+ eventType = "hello_provisioning_willlaunch";
+ severity = EventSeverity.Info;
+ message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
+ _logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
+ break;
+
+ case EventId_NgcKeyRegistered: // 300
+ eventType = "hello_provisioning_completed";
+ severity = EventSeverity.Info;
+ message = "Windows Hello for Business provisioned successfully - NGC key registered";
+ shouldTriggerHelloCompleted = MarkHelloCompleted();
+ _logger.Info("Windows Hello provisioning COMPLETED successfully");
+ break;
+
+ case EventId_NgcKeyRegistrationFailed: // 301
+ eventType = "hello_provisioning_failed";
+ severity = EventSeverity.Error;
+ message = "Windows Hello for Business provisioning failed - NGC key registration error";
+ shouldTriggerHelloCompleted = MarkHelloCompleted();
+ _logger.Info("Windows Hello provisioning COMPLETED with failure");
+ break;
+
+ case EventId_ProvisioningWillNotLaunch: // 360
+ eventType = "hello_provisioning_willnotlaunch";
+ severity = EventSeverity.Warning;
+ message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
+ // DO NOT mark Hello as completed - event 360 is just a snapshot and can change
+ _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
+ break;
+
+ case EventId_ProvisioningBlocked: // 362
+ eventType = "hello_provisioning_blocked";
+ severity = EventSeverity.Warning;
+ message = "Windows Hello for Business provisioning blocked";
+ shouldTriggerHelloCompleted = MarkHelloCompleted();
+ _logger.Info("Windows Hello provisioning COMPLETED (blocked)");
+ break;
+
+ case EventId_PinStatus: // 376
+ eventType = "hello_pin_status";
+ severity = EventSeverity.Info;
+ message = "Windows Hello PIN status update";
+ break;
+
+ default:
+ return;
+ }
+
+ // We only need the event ID and provider for tracking purposes.
+ // The full description can contain sensitive data (e.g. server responses with key IDs, UPNs, tokens).
+ var data = new Dictionary
+ {
+ { "windowsEventId", eventId },
+ { "providerName", providerName ?? "" },
+ { "description", "truncated" },
+ { "eventTime", timestamp.ToString("o") }
+ };
+
+ if (isBackfill)
+ {
+ data["backfill"] = true;
+ }
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = timestamp,
+ EventType = eventType,
+ Severity = severity,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = data
+ });
+
+ _logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
+
+ if (shouldTriggerHelloCompleted)
+ {
+ try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
+ }
+ }
+
+ private bool MarkHelloCompleted()
+ {
+ lock (_stateLock)
+ {
+ if (_isHelloCompleted)
+ {
+ _logger.Debug("Hello terminal event received but Hello is already marked completed");
+ return false;
+ }
+
+ _isHelloCompleted = true;
+ HelloOutcome = "completed";
+ StopHelloCompletionTimerLocked();
+ return true;
+ }
+ }
+
+ ///
+ /// Called when the Hello wait timer expires after ESP exit.
+ /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped.
+ /// Mark Hello as completed so enrollment can proceed.
+ ///
+ private void OnHelloWaitTimeout(object state)
+ {
+ lock (_stateLock)
+ {
+ if (_helloWizardStarted)
+ {
+ // Hello wizard already started, this timeout is obsolete
+ _logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
+ return;
+ }
+
+ if (_isHelloCompleted)
+ {
+ // Hello already completed via events 300/301 - timeout is obsolete
+ _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
+ return;
+ }
+
+ // Timeout expired without Hello wizard starting.
+ // If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
+ // (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
+ // complete — start the long HelloCompletion timer and keep waiting.
+ // If Hello policy is disabled/unknown, assume Hello is not configured and proceed.
+ if (_isHelloPolicyEnabled)
+ {
+ _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
+ $"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "hello_wait_timeout",
+ Severity = EventSeverity.Info,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
+ $"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
+ Data = new Dictionary
+ {
+ { "timeoutSeconds", _helloWaitTimeoutSeconds },
+ { "espExitDetected", _espExitDetected },
+ { "helloPolicyEnabled", true },
+ { "action", "extended_wait" }
+ }
+ });
+
+ // Arm the long completion timer so we don't wait forever
+ StartHelloCompletionTimerLocked();
+ return;
+ }
+
+ // Hello policy disabled or unknown — assume Hello is not configured
+ _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
+ _logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
+
+ _isHelloCompleted = true;
+ HelloOutcome = "not_configured";
+ StopHelloCompletionTimerLocked();
+
+ // Emit event for tracking
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "hello_wait_timeout",
+ Severity = EventSeverity.Info,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
+ Data = new Dictionary
+ {
+ { "timeoutSeconds", _helloWaitTimeoutSeconds },
+ { "espExitDetected", _espExitDetected },
+ { "helloPolicyEnabled", false },
+ { "action", "enrollment_complete" }
+ }
+ });
+
+ // Trigger HelloCompleted so enrollment can proceed
+ try
+ {
+ HelloCompleted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error invoking HelloCompleted from timeout", ex);
+ }
+ }
+ }
+
+ ///
+ /// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits.
+ /// The timer waits for Hello wizard to start (event 62404) within the configured timeout.
+ /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
+ ///
+ public void StartHelloWaitTimer()
+ {
+ lock (_stateLock)
+ {
+ // Don't start if Hello already started or completed
+ if (_helloWizardStarted)
+ {
+ _logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
+ return;
+ }
+
+ if (_isHelloCompleted)
+ {
+ _logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
+ return;
+ }
+
+ // Don't start if timer already running
+ if (_helloWaitTimer != null)
+ {
+ _logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
+ return;
+ }
+
+ _logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
+ _helloWaitTimer = new System.Threading.Timer(
+ OnHelloWaitTimeout,
+ null,
+ TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1) // One-shot timer
+ );
+ }
+ }
+
+ private void StartHelloCompletionTimerLocked()
+ {
+ if (_isHelloCompleted)
+ return;
+
+ if (_helloCompletionTimer != null)
+ return;
+
+ _logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
+ _helloCompletionTimer = new System.Threading.Timer(
+ OnHelloCompletionTimeout,
+ null,
+ TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+ }
+
+ private void StopHelloCompletionTimerLocked()
+ {
+ if (_helloCompletionTimer == null)
+ return;
+
+ try
+ {
+ _helloCompletionTimer.Dispose();
+ }
+ catch { }
+ finally
+ {
+ _helloCompletionTimer = null;
+ }
+ }
+
+ private void OnHelloCompletionTimeout(object state)
+ {
+ lock (_stateLock)
+ {
+ if (_isHelloCompleted)
+ {
+ _logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
+ return;
+ }
+
+ _logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
+ _isHelloCompleted = true;
+ HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
+ StopHelloCompletionTimerLocked();
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "hello_completion_timeout",
+ Severity = EventSeverity.Warning,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
+ Data = new Dictionary
+ {
+ { "timeoutSeconds", HelloCompletionTimeoutSeconds },
+ { "helloWizardStarted", _helloWizardStarted }
+ }
+ });
+
+ try
+ {
+ HelloCompleted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error invoking HelloCompleted from completion timeout", ex);
+ }
+ }
+ }
+
+ private void BackfillRecentTerminalHelloEvents()
+ {
+ try
+ {
+ var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
+ var query = new EventLogQuery(
+ EventLogChannel,
+ PathType.LogName,
+ $"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
+
+ using (var reader = new EventLogReader(query))
+ {
+ DateTime latestTimestamp = DateTime.MinValue;
+ int? latestEventId = null;
+ string latestProvider = string.Empty;
+
+ for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
+ {
+ using (record)
+ {
+ var eventId = record.Id;
+ if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
+ continue;
+
+ var timestamp = record.TimeCreated ?? DateTime.MinValue;
+ if (timestamp < latestTimestamp)
+ continue;
+
+ latestTimestamp = timestamp;
+ latestEventId = eventId;
+ latestProvider = record.ProviderName ?? "";
+ }
+ }
+
+ if (latestEventId.HasValue)
+ {
+ _logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
+ ProcessHelloEvent(latestEventId.Value, (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), latestProvider, isBackfill: true);
+ }
+ else
+ {
+ _logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs
new file mode 100644
index 00000000..7bddd9ec
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs
@@ -0,0 +1,347 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Eventing.Reader;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
+{
+ ///
+ /// Partial: Shell-Core event log handling — ESP exit detection, ESP failure detection,
+ /// WhiteGlove detection, Hello wizard start detection, and ESP backfill.
+ ///
+ public partial class EspAndHelloTracker
+ {
+ private void StartShellCoreEventLogWatcher()
+ {
+ try
+ {
+ // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
+ var query = new EventLogQuery(
+ ShellCoreEventLogChannel,
+ PathType.LogName,
+ "*[System[(EventID=62404 or EventID=62407)]]"
+ );
+
+ _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
+ _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
+ _shellCoreWatcher.Enabled = true;
+
+ _logger.Info($"Started watching: {ShellCoreEventLogChannel}");
+ }
+ catch (EventLogNotFoundException)
+ {
+ _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Failed to start Shell-Core event log watcher", ex);
+ }
+ }
+
+ private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
+ {
+ if (e.EventRecord == null)
+ return;
+
+ try
+ {
+ var record = e.EventRecord;
+ var eventId = record.Id;
+
+ if (!TrackedShellCoreEventIds.Contains(eventId))
+ return;
+
+ var description = record.FormatDescription() ?? $"Event ID {eventId}";
+
+ string eventType;
+ EventSeverity severity = EventSeverity.Info;
+ string message;
+ bool triggerFinalizingSetup = false;
+ string finalizingSetupReason = null;
+
+ switch (eventId)
+ {
+ case EventId_ShellCore_WebAppStarted: // 62404
+ // Check if this is AADHello or NGC (Hello wizard started)
+ if (description.Contains("AADHello") || description.Contains("'NGC'"))
+ {
+ eventType = "hello_wizard_started";
+ message = "Windows Hello wizard started (CloudExperienceHost)";
+ triggerFinalizingSetup = true;
+ finalizingSetupReason = "hello_wizard_started";
+
+ lock (_stateLock)
+ {
+ _helloWizardStarted = true;
+
+ // Stop the hello wait timer if running (Hello wizard appeared within timeout)
+ if (_helloWaitTimer != null)
+ {
+ _helloWaitTimer.Dispose();
+ _helloWaitTimer = null;
+ _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
+ }
+
+ StartHelloCompletionTimerLocked();
+ }
+
+ _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
+ }
+ else
+ {
+ // Not Hello-related, ignore
+ return;
+ }
+ break;
+
+ //{
+ //"windowsEventId": 62407,
+ //"providerName": "Microsoft-Windows-Shell-Core",
+ //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.",
+ //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
+ //}
+
+ //{
+ //"windowsEventId": 62407,
+ //"providerName": "Microsoft-Windows-Shell-Core",
+ //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.",
+ //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
+ //}
+
+ //{
+ //"windowsEventId": 62407,
+ //"providerName": "Microsoft-Windows-Shell-Core",
+ //"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.",
+ //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
+ //}
+
+ //{
+ //"windowsEventId": 62407,
+ //"providerName": "Microsoft-Windows-Shell-Core",
+ //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.",
+ //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
+ //}
+
+ case EventId_ShellCore_WebAppEvent: // 62407
+ // WhiteGlove check FIRST — its description also contains "Exiting"
+ // which would match the generic OOBE_ESP.*Exiting regex below.
+ if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase))
+ {
+ // Guard: event 62407 can fire multiple times; only process once
+ lock (_stateLock)
+ {
+ if (_whiteGloveDetected) return;
+ _whiteGloveDetected = true;
+ }
+
+ eventType = "whiteglove_complete";
+ message = "WhiteGlove (Pre-Provisioning) completed successfully";
+ // Do NOT set triggerFinalizingSetup — WhiteGlove terminates the
+ // pre-provisioning phase entirely, it does not transition to FinalizingSetup.
+
+ _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
+ }
+ // Check if this is ESP exit event
+ // Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
+ // Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description.
+ // Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above.
+ else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
+ {
+ var failureType = ExtractEspFailureType(description);
+ eventType = "esp_failure";
+ severity = EventSeverity.Error;
+ message = $"ESP (Enrollment Status Page) reported a failure: {failureType}";
+ _logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}");
+ _detectedEspFailureType = failureType;
+ }
+ else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ {
+ eventType = "esp_exiting";
+ message = "ESP (Enrollment Status Page) phase exiting";
+ triggerFinalizingSetup = true;
+ finalizingSetupReason = "esp_exiting";
+
+ lock (_stateLock)
+ {
+ _espExitDetected = true;
+
+ // NOTE: We do NOT start the Hello wait timer here!
+ // Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
+ // EnrollmentTracker will decide based on _lastEspPhase whether to start the timer
+ }
+
+ _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
+ }
+ else
+ {
+ // Not a tracked ESP event, ignore
+ return;
+ }
+ break;
+
+ default:
+ return;
+ }
+
+ var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
+
+ var eventData = new Dictionary
+ {
+ { "windowsEventId", eventId },
+ { "providerName", record.ProviderName ?? "" },
+ { "description", description },
+ { "eventLogChannel", ShellCoreEventLogChannel },
+ { "eventTime", eventTimestamp.ToString("o") }
+ };
+
+ if (eventType == "esp_failure" && _detectedEspFailureType != null)
+ {
+ eventData["failureType"] = _detectedEspFailureType;
+ }
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = eventTimestamp,
+ EventType = eventType,
+ Severity = severity,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
+ Message = message,
+ Data = eventData
+ });
+
+ _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
+
+ // Trigger FinalizingSetup phase transition
+ if (triggerFinalizingSetup)
+ {
+ try
+ {
+ FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
+ }
+ catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
+ }
+
+ // Fire WhiteGloveCompleted if this was a WhiteGlove success event.
+ // Must happen AFTER the event has been emitted above so the
+ // whiteglove_complete event is in the spool before the agent exits.
+ if (eventType == "whiteglove_complete")
+ {
+ try
+ {
+ WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
+ }
+
+ // Fire EspFailureDetected if this was an ESP failure event.
+ // Must happen AFTER the event has been emitted above so the
+ // esp_failure event is in the spool before the agent potentially shuts down.
+ if (eventType == "esp_failure" && _detectedEspFailureType != null)
+ {
+ var failureType = _detectedEspFailureType;
+ _detectedEspFailureType = null;
+ try
+ {
+ EspFailureDetected?.Invoke(this, failureType);
+ }
+ catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error processing Shell-Core event record", ex);
+ }
+ }
+
+ ///
+ /// Extracts a structured failure type from the Shell-Core event description.
+ /// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed".
+ ///
+ private static string ExtractEspFailureType(string description)
+ {
+ // Known failure type keywords to search for in order of specificity
+ string[] knownTypes = {
+ "ESPProgress_Failure",
+ "ESPProgress_Failed",
+ "ESPProgress_Timeout",
+ "ESPProgress_Abort",
+ "WhiteGlove_Failed",
+ "WhiteGlove_Failure"
+ };
+
+ foreach (var type in knownTypes)
+ {
+ if (description.Contains(type, StringComparison.OrdinalIgnoreCase))
+ return type;
+ }
+
+ return "Unknown_ESP_Failure";
+ }
+
+ ///
+ /// Backfills recent ESP exit and failure events from Shell-Core log on startup.
+ /// Secondary recovery mechanism when state persistence is unavailable.
+ ///
+ public void BackfillRecentEspExitEvents()
+ {
+ try
+ {
+ var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
+ var query = new EventLogQuery(
+ ShellCoreEventLogChannel,
+ PathType.LogName,
+ $"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
+
+ using (var reader = new EventLogReader(query))
+ {
+ for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
+ {
+ using (record)
+ {
+ var description = record.FormatDescription() ?? "";
+
+ // Check for ESP exit
+ if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
+ {
+ lock (_stateLock)
+ {
+ if (!_espExitDetected)
+ {
+ _espExitDetected = true;
+ _logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
+ try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
+ }
+ }
+ }
+
+ // Check for ESP failures
+ if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
+ || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
+ {
+ var failureType = ExtractEspFailureType(description);
+ _logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
+ try { EspFailureDetected?.Invoke(this, failureType); } catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
+ }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs
index 94df7dfe..cdc4d064 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove (Pre-Provisioning) complete
///
- public class EspAndHelloTracker : IDisposable
+ public partial class EspAndHelloTracker : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
@@ -259,878 +259,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
- private void CheckHelloPolicy()
- {
- try
- {
- var (isEnabled, source) = DetectHelloPolicy();
-
- lock (_stateLock)
- {
- // If policy was not configured before but is now, update state and emit event
- if (!_isPolicyConfigured && isEnabled.HasValue)
- {
- _isPolicyConfigured = true;
- _isHelloPolicyEnabled = isEnabled.Value;
- var status = isEnabled.Value ? "enabled" : "disabled";
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "hello_policy_detected",
- Severity = EventSeverity.Info,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Windows Hello for Business policy detected: {status} (via {source})",
- Data = new Dictionary
- {
- { "helloEnabled", isEnabled.Value },
- { "policySource", source }
- }
- });
-
- _logger.Info($"WHfB policy detected: {status} (source: {source})");
-
- // Stop periodic policy check - we found what we were looking for
- if (_policyCheckTimer != null)
- {
- _policyCheckTimer.Dispose();
- _policyCheckTimer = null;
- _logger.Debug("Stopped periodic Hello policy check - policy has been detected");
- }
- }
- else if (!_isPolicyConfigured && !isEnabled.HasValue)
- {
- // Policy still not found - this is normal during early enrollment (Debug level to keep UI clean)
- _logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
- }
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"Error checking WHfB policy: {ex.Message}");
- }
- }
-
- private (bool? isEnabled, string source) DetectHelloPolicy()
- {
- // 1. Check CSP/Intune-delivered policy (per-tenant paths)
- // Policy can be device-scoped: {tenantId}\Device\Policies
- // or user-scoped: {tenantId}\{userSid}\Policies
- try
- {
- using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
- {
- if (baseCspKey != null)
- {
- foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
- {
- using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
- {
- if (tenantKey != null)
- {
- // Check all subkeys (Device or user SIDs like S-1-5-...)
- foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
- {
- using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
- {
- if (policiesKey != null)
- {
- var value = policiesKey.GetValue("UsePassportForWork");
- if (value != null)
- {
- var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
- ? "device"
- : "user";
- return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
- catch { }
-
- // 2. Check GPO-delivered policy
- try
- {
- using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
- {
- if (gpoKey != null)
- {
- var value = gpoKey.GetValue("Enabled");
- if (value != null)
- {
- return (Convert.ToInt32(value) == 1, "GPO");
- }
- }
- }
- }
- catch { }
-
- return (null, null);
- }
-
- private void StartEventLogWatcher()
- {
- try
- {
- // Watch for specific WHfB-related event IDs
- var query = new EventLogQuery(
- EventLogChannel,
- PathType.LogName,
- "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"
- );
-
- _watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
- _watcher.EventRecordWritten += OnEventRecordWritten;
- _watcher.Enabled = true;
-
- _logger.Info($"Started watching: {EventLogChannel}");
- }
- catch (EventLogNotFoundException)
- {
- _logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)");
- }
- catch (Exception ex)
- {
- _logger.Error($"Failed to start Hello event log watcher", ex);
- }
- }
-
- private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
- {
- if (e.EventRecord == null)
- return;
-
- try
- {
- var record = e.EventRecord;
- var eventId = record.Id;
- if (!TrackedEventIds.Contains(eventId))
- return;
-
- ProcessHelloEvent(
- eventId,
- (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
- record.ProviderName ?? "",
- isBackfill: false);
- }
- catch (Exception ex)
- {
- _logger.Error("Error processing Hello event record", ex);
- }
- }
-
- private void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
- {
- string eventType;
- EventSeverity severity;
- string message;
- bool shouldTriggerHelloCompleted = false;
-
- switch (eventId)
- {
- case EventId_ProvisioningWillLaunch: // 358
- eventType = "hello_provisioning_willlaunch";
- severity = EventSeverity.Info;
- message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
- _logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
- break;
-
- case EventId_NgcKeyRegistered: // 300
- eventType = "hello_provisioning_completed";
- severity = EventSeverity.Info;
- message = "Windows Hello for Business provisioned successfully - NGC key registered";
- shouldTriggerHelloCompleted = MarkHelloCompleted();
- _logger.Info("Windows Hello provisioning COMPLETED successfully");
- break;
-
- case EventId_NgcKeyRegistrationFailed: // 301
- eventType = "hello_provisioning_failed";
- severity = EventSeverity.Error;
- message = "Windows Hello for Business provisioning failed - NGC key registration error";
- shouldTriggerHelloCompleted = MarkHelloCompleted();
- _logger.Info("Windows Hello provisioning COMPLETED with failure");
- break;
-
- case EventId_ProvisioningWillNotLaunch: // 360
- eventType = "hello_provisioning_willnotlaunch";
- severity = EventSeverity.Warning;
- message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
- // DO NOT mark Hello as completed - event 360 is just a snapshot and can change
- _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
- break;
-
- case EventId_ProvisioningBlocked: // 362
- eventType = "hello_provisioning_blocked";
- severity = EventSeverity.Warning;
- message = "Windows Hello for Business provisioning blocked";
- shouldTriggerHelloCompleted = MarkHelloCompleted();
- _logger.Info("Windows Hello provisioning COMPLETED (blocked)");
- break;
-
- case EventId_PinStatus: // 376
- eventType = "hello_pin_status";
- severity = EventSeverity.Info;
- message = "Windows Hello PIN status update";
- break;
-
- default:
- return;
- }
-
- // We only need the event ID and provider for tracking purposes.
- // The full description can contain sensitive data (e.g. server responses with key IDs, UPNs, tokens).
- var data = new Dictionary
- {
- { "windowsEventId", eventId },
- { "providerName", providerName ?? "" },
- { "description", "truncated" },
- { "eventTime", timestamp.ToString("o") }
- };
-
- if (isBackfill)
- {
- data["backfill"] = true;
- }
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- Timestamp = timestamp,
- EventType = eventType,
- Severity = severity,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = message,
- Data = data
- });
-
- _logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
-
- if (shouldTriggerHelloCompleted)
- {
- try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
- }
- }
-
- private bool MarkHelloCompleted()
- {
- lock (_stateLock)
- {
- if (_isHelloCompleted)
- {
- _logger.Debug("Hello terminal event received but Hello is already marked completed");
- return false;
- }
-
- _isHelloCompleted = true;
- HelloOutcome = "completed";
- StopHelloCompletionTimerLocked();
- return true;
- }
- }
-
- private void StartShellCoreEventLogWatcher()
- {
- try
- {
- // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log
- var query = new EventLogQuery(
- ShellCoreEventLogChannel,
- PathType.LogName,
- "*[System[(EventID=62404 or EventID=62407)]]"
- );
-
- _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
- _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten;
- _shellCoreWatcher.Enabled = true;
-
- _logger.Info($"Started watching: {ShellCoreEventLogChannel}");
- }
- catch (EventLogNotFoundException)
- {
- _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
- }
- catch (Exception ex)
- {
- _logger.Error($"Failed to start Shell-Core event log watcher", ex);
- }
- }
-
- private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
- {
- if (e.EventRecord == null)
- return;
-
- try
- {
- var record = e.EventRecord;
- var eventId = record.Id;
-
- if (!TrackedShellCoreEventIds.Contains(eventId))
- return;
-
- var description = record.FormatDescription() ?? $"Event ID {eventId}";
-
- string eventType;
- EventSeverity severity = EventSeverity.Info;
- string message;
- bool triggerFinalizingSetup = false;
- string finalizingSetupReason = null;
-
- switch (eventId)
- {
- case EventId_ShellCore_WebAppStarted: // 62404
- // Check if this is AADHello or NGC (Hello wizard started)
- if (description.Contains("AADHello") || description.Contains("'NGC'"))
- {
- eventType = "hello_wizard_started";
- message = "Windows Hello wizard started (CloudExperienceHost)";
- triggerFinalizingSetup = true;
- finalizingSetupReason = "hello_wizard_started";
-
- lock (_stateLock)
- {
- _helloWizardStarted = true;
-
- // Stop the hello wait timer if running (Hello wizard appeared within timeout)
- if (_helloWaitTimer != null)
- {
- _helloWaitTimer.Dispose();
- _helloWaitTimer = null;
- _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
- }
-
- StartHelloCompletionTimerLocked();
- }
-
- _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
- }
- else
- {
- // Not Hello-related, ignore
- return;
- }
- break;
-
- //{
- //"windowsEventId": 62407,
- //"providerName": "Microsoft-Windows-Shell-Core",
- //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.",
- //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
- //}
-
- //{
- //"windowsEventId": 62407,
- //"providerName": "Microsoft-Windows-Shell-Core",
- //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.",
- //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
- //}
-
- //{
- //"windowsEventId": 62407,
- //"providerName": "Microsoft-Windows-Shell-Core",
- //"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.",
- //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
- //}
-
- //{
- //"windowsEventId": 62407,
- //"providerName": "Microsoft-Windows-Shell-Core",
- //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.",
- //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational"
- //}
-
- case EventId_ShellCore_WebAppEvent: // 62407
- // WhiteGlove check FIRST — its description also contains "Exiting"
- // which would match the generic OOBE_ESP.*Exiting regex below.
- if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase))
- {
- // Guard: event 62407 can fire multiple times; only process once
- lock (_stateLock)
- {
- if (_whiteGloveDetected) return;
- _whiteGloveDetected = true;
- }
-
- eventType = "whiteglove_complete";
- message = "WhiteGlove (Pre-Provisioning) completed successfully";
- // Do NOT set triggerFinalizingSetup — WhiteGlove terminates the
- // pre-provisioning phase entirely, it does not transition to FinalizingSetup.
-
- _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
- }
- // Check if this is ESP exit event
- // Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting
- // Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description.
- // Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above.
- else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
- || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
- || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
- {
- var failureType = ExtractEspFailureType(description);
- eventType = "esp_failure";
- severity = EventSeverity.Error;
- message = $"ESP (Enrollment Status Page) reported a failure: {failureType}";
- _logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}");
- _detectedEspFailureType = failureType;
- }
- else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
- {
- eventType = "esp_exiting";
- message = "ESP (Enrollment Status Page) phase exiting";
- triggerFinalizingSetup = true;
- finalizingSetupReason = "esp_exiting";
-
- lock (_stateLock)
- {
- _espExitDetected = true;
-
- // NOTE: We do NOT start the Hello wait timer here!
- // Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
- // EnrollmentTracker will decide based on _lastEspPhase whether to start the timer
- }
-
- _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
- }
- else
- {
- // Not a tracked ESP event, ignore
- return;
- }
- break;
-
- default:
- return;
- }
-
- var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
-
- var eventData = new Dictionary
- {
- { "windowsEventId", eventId },
- { "providerName", record.ProviderName ?? "" },
- { "description", description },
- { "eventLogChannel", ShellCoreEventLogChannel },
- { "eventTime", eventTimestamp.ToString("o") }
- };
-
- if (eventType == "esp_failure" && _detectedEspFailureType != null)
- {
- eventData["failureType"] = _detectedEspFailureType;
- }
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- Timestamp = eventTimestamp,
- EventType = eventType,
- Severity = severity,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase
- Message = message,
- Data = eventData
- });
-
- _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
-
- // Trigger FinalizingSetup phase transition
- if (triggerFinalizingSetup)
- {
- try
- {
- FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason);
- }
- catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
- }
-
- // Fire WhiteGloveCompleted if this was a WhiteGlove success event.
- // Must happen AFTER the event has been emitted above so the
- // whiteglove_complete event is in the spool before the agent exits.
- if (eventType == "whiteglove_complete")
- {
- try
- {
- WhiteGloveCompleted?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
- }
-
- // Fire EspFailureDetected if this was an ESP failure event.
- // Must happen AFTER the event has been emitted above so the
- // esp_failure event is in the spool before the agent potentially shuts down.
- if (eventType == "esp_failure" && _detectedEspFailureType != null)
- {
- var failureType = _detectedEspFailureType;
- _detectedEspFailureType = null;
- try
- {
- EspFailureDetected?.Invoke(this, failureType);
- }
- catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
- }
- }
- catch (Exception ex)
- {
- _logger.Error("Error processing Shell-Core event record", ex);
- }
- }
-
- ///
- /// Called when the Hello wait timer expires after ESP exit.
- /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped.
- /// Mark Hello as completed so enrollment can proceed.
- ///
- private void OnHelloWaitTimeout(object state)
- {
- lock (_stateLock)
- {
- if (_helloWizardStarted)
- {
- // Hello wizard already started, this timeout is obsolete
- _logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
- return;
- }
-
- if (_isHelloCompleted)
- {
- // Hello already completed via events 300/301 - timeout is obsolete
- _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
- return;
- }
-
- // Timeout expired without Hello wizard starting.
- // If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
- // (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
- // complete — start the long HelloCompletion timer and keep waiting.
- // If Hello policy is disabled/unknown, assume Hello is not configured and proceed.
- if (_isHelloPolicyEnabled)
- {
- _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
- $"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "hello_wait_timeout",
- Severity = EventSeverity.Info,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
- $"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
- Data = new Dictionary
- {
- { "timeoutSeconds", _helloWaitTimeoutSeconds },
- { "espExitDetected", _espExitDetected },
- { "helloPolicyEnabled", true },
- { "action", "extended_wait" }
- }
- });
-
- // Arm the long completion timer so we don't wait forever
- StartHelloCompletionTimerLocked();
- return;
- }
-
- // Hello policy disabled or unknown — assume Hello is not configured
- _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
- _logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
-
- _isHelloCompleted = true;
- HelloOutcome = "not_configured";
- StopHelloCompletionTimerLocked();
-
- // Emit event for tracking
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "hello_wait_timeout",
- Severity = EventSeverity.Info,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
- Data = new Dictionary
- {
- { "timeoutSeconds", _helloWaitTimeoutSeconds },
- { "espExitDetected", _espExitDetected },
- { "helloPolicyEnabled", false },
- { "action", "enrollment_complete" }
- }
- });
-
- // Trigger HelloCompleted so enrollment can proceed
- try
- {
- HelloCompleted?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.Error("Error invoking HelloCompleted from timeout", ex);
- }
- }
- }
-
- ///
- /// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits.
- /// The timer waits for Hello wizard to start (event 62404) within the configured timeout.
- /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
- ///
- public void StartHelloWaitTimer()
- {
- lock (_stateLock)
- {
- // Don't start if Hello already started or completed
- if (_helloWizardStarted)
- {
- _logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
- return;
- }
-
- if (_isHelloCompleted)
- {
- _logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
- return;
- }
-
- // Don't start if timer already running
- if (_helloWaitTimer != null)
- {
- _logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
- return;
- }
-
- _logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
- _helloWaitTimer = new System.Threading.Timer(
- OnHelloWaitTimeout,
- null,
- TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1) // One-shot timer
- );
- }
- }
-
- private void StartHelloCompletionTimerLocked()
- {
- if (_isHelloCompleted)
- return;
-
- if (_helloCompletionTimer != null)
- return;
-
- _logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
- _helloCompletionTimer = new System.Threading.Timer(
- OnHelloCompletionTimeout,
- null,
- TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
- }
-
- private void StopHelloCompletionTimerLocked()
- {
- if (_helloCompletionTimer == null)
- return;
-
- try
- {
- _helloCompletionTimer.Dispose();
- }
- catch { }
- finally
- {
- _helloCompletionTimer = null;
- }
- }
-
- private void OnHelloCompletionTimeout(object state)
- {
- lock (_stateLock)
- {
- if (_isHelloCompleted)
- {
- _logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
- return;
- }
-
- _logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
- _isHelloCompleted = true;
- HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
- StopHelloCompletionTimerLocked();
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "hello_completion_timeout",
- Severity = EventSeverity.Warning,
- Source = "EspAndHelloTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
- Data = new Dictionary
- {
- { "timeoutSeconds", HelloCompletionTimeoutSeconds },
- { "helloWizardStarted", _helloWizardStarted }
- }
- });
-
- try
- {
- HelloCompleted?.Invoke(this, EventArgs.Empty);
- }
- catch (Exception ex)
- {
- _logger.Error("Error invoking HelloCompleted from completion timeout", ex);
- }
- }
- }
-
- ///
- /// Extracts a structured failure type from the Shell-Core event description.
- /// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed".
- ///
- private static string ExtractEspFailureType(string description)
- {
- // Known failure type keywords to search for in order of specificity
- string[] knownTypes = {
- "ESPProgress_Failure",
- "ESPProgress_Failed",
- "ESPProgress_Timeout",
- "ESPProgress_Abort",
- "WhiteGlove_Failed",
- "WhiteGlove_Failure"
- };
-
- foreach (var type in knownTypes)
- {
- if (description.Contains(type, StringComparison.OrdinalIgnoreCase))
- return type;
- }
-
- return "Unknown_ESP_Failure";
- }
-
- ///
- /// Backfills recent ESP exit and failure events from Shell-Core log on startup.
- /// Secondary recovery mechanism when state persistence is unavailable.
- ///
- public void BackfillRecentEspExitEvents()
- {
- try
- {
- var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
- var query = new EventLogQuery(
- ShellCoreEventLogChannel,
- PathType.LogName,
- $"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
-
- using (var reader = new EventLogReader(query))
- {
- for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
- {
- using (record)
- {
- var description = record.FormatDescription() ?? "";
-
- // Check for ESP exit
- if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
- {
- lock (_stateLock)
- {
- if (!_espExitDetected)
- {
- _espExitDetected = true;
- _logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
- try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
- }
- }
- }
-
- // Check for ESP failures
- if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase)
- || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase)
- || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase)
- || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase))
- {
- var failureType = ExtractEspFailureType(description);
- _logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
- try { EspFailureDetected?.Invoke(this, failureType); } catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
- }
- }
- }
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
- }
- }
-
- private void BackfillRecentTerminalHelloEvents()
- {
- try
- {
- var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
- var query = new EventLogQuery(
- EventLogChannel,
- PathType.LogName,
- $"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
-
- using (var reader = new EventLogReader(query))
- {
- DateTime latestTimestamp = DateTime.MinValue;
- int? latestEventId = null;
- string latestProvider = string.Empty;
-
- for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
- {
- using (record)
- {
- var eventId = record.Id;
- if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
- continue;
-
- var timestamp = record.TimeCreated ?? DateTime.MinValue;
- if (timestamp < latestTimestamp)
- continue;
-
- latestTimestamp = timestamp;
- latestEventId = eventId;
- latestProvider = record.ProviderName ?? "";
- }
- }
-
- if (latestEventId.HasValue)
- {
- _logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
- ProcessHelloEvent(latestEventId.Value, (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), latestProvider, isBackfill: true);
- }
- else
- {
- _logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
- }
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
- }
- }
-
public void Dispose()
{
Stop();
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs
new file mode 100644
index 00000000..eda929c9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs
@@ -0,0 +1,716 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.Eventing.Reader;
+using System.IO;
+using System.Linq;
+using System.Management;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
+{
+ ///
+ /// Partial: Individual rule executor implementations (registry, WMI, command, file, eventlog, logparser).
+ ///
+ public partial class GatherRuleExecutor
+ {
+ private Dictionary ExecuteRegistryRule(GatherRule rule)
+ {
+ var data = new Dictionary();
+ var path = rule.Target;
+
+ if (string.IsNullOrEmpty(path))
+ return data;
+
+ // Determine hive
+ RegistryKey hive = null;
+ string subPath = path;
+
+ if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
+ {
+ hive = Registry.LocalMachine;
+ subPath = path.Substring(path.IndexOf('\\') + 1);
+ }
+ else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
+ {
+ hive = Registry.CurrentUser;
+ subPath = path.Substring(path.IndexOf('\\') + 1);
+ }
+ else
+ {
+ // Default to HKLM
+ hive = Registry.LocalMachine;
+ }
+
+ // Guard: only allow enrollment-relevant registry paths
+ if (!GatherRuleGuards.IsRegistryPathAllowed(subPath))
+ return EmitSecurityWarning(rule, "registry", path);
+
+ // Determine explicit valueName from parameters
+ string explicitValueName = null;
+ rule.Parameters?.TryGetValue("valueName", out explicitValueName);
+
+ try
+ {
+ using (var key = hive.OpenSubKey(subPath, false))
+ {
+ if (key == null)
+ {
+ // The key doesn't exist as a sub-key. If no explicit valueName was given,
+ // check whether the last path segment is a value name in the parent key.
+ // This handles the common case where the user specifies the full
+ // "HKLM\...\KeyName\ValueName" path directly in Target.
+ if (string.IsNullOrEmpty(explicitValueName))
+ {
+ var lastBackslash = subPath.LastIndexOf('\\');
+ if (lastBackslash > 0)
+ {
+ var parentSubPath = subPath.Substring(0, lastBackslash);
+ var inferredValueName = subPath.Substring(lastBackslash + 1);
+
+ using (var parentKey = hive.OpenSubKey(parentSubPath, false))
+ {
+ if (parentKey != null)
+ {
+ var valueNames = parentKey.GetValueNames();
+ if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
+ {
+ var value = parentKey.GetValue(inferredValueName);
+ data["exists"] = true;
+ data["path"] = path.Substring(0, path.LastIndexOf('\\'));
+ data[inferredValueName] = value?.ToString();
+ return data;
+ }
+ }
+ }
+ }
+ }
+
+ data["exists"] = false;
+ data["path"] = path;
+ return data;
+ }
+
+ data["exists"] = true;
+ data["path"] = path;
+
+ // Read specific value if specified in parameters
+ if (!string.IsNullOrEmpty(explicitValueName))
+ {
+ var value = key.GetValue(explicitValueName);
+ data[explicitValueName] = value?.ToString();
+ }
+ else
+ {
+ // Read all values
+ foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
+ {
+ try
+ {
+ var value = key.GetValue(name);
+ var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
+ data[displayName] = value?.ToString();
+ }
+ catch { }
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ data["error"] = ex.Message;
+ }
+
+ return data;
+ }
+
+ private Dictionary ExecuteWmiRule(GatherRule rule)
+ {
+ var data = new Dictionary();
+ var query = rule.Target;
+
+ if (string.IsNullOrEmpty(query))
+ return data;
+
+ // Guard: only allow known-safe WMI classes
+ if (!GatherRuleGuards.IsWmiQueryAllowed(query))
+ return EmitSecurityWarning(rule, "wmi", query);
+
+ try
+ {
+ using (var searcher = new ManagementObjectSearcher(query))
+ {
+ int index = 0;
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ var item = new Dictionary();
+ foreach (var prop in obj.Properties)
+ {
+ try
+ {
+ item[prop.Name] = prop.Value?.ToString();
+ }
+ catch { }
+ }
+
+ if (item.Count > 0)
+ {
+ // For single result, flatten; for multiple, use indexed keys
+ if (index == 0)
+ {
+ foreach (var kvp in item)
+ data[kvp.Key] = kvp.Value;
+ }
+ else
+ {
+ data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
+ }
+ }
+ index++;
+
+ if (index >= 20) break; // Limit results
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ data["error"] = ex.Message;
+ }
+
+ return data;
+ }
+
+ private Dictionary ExecuteCommandRule(GatherRule rule)
+ {
+ var data = new Dictionary();
+ var command = rule.Target;
+
+ if (string.IsNullOrEmpty(command))
+ return data;
+
+ // SECURITY: Check command against allowlist
+ if (!GatherRuleGuards.IsCommandAllowed(command))
+ {
+ _logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
+ data["blocked"] = true;
+ data["reason"] = "Command not on allowlist";
+ data["command"] = command;
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "security_warning",
+ Severity = EventSeverity.Warning,
+ Source = "GatherRuleExecutor",
+ Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
+ Data = data
+ });
+
+ return data;
+ }
+
+ try
+ {
+ var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
+ !command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
+ !command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
+ !command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
+ !command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
+
+ ProcessStartInfo psi;
+ if (isPowerShell)
+ {
+ // Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
+ var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
+ psi = new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ }
+ else
+ {
+ psi = new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/c {command}",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ }
+
+ using (var process = Process.Start(psi))
+ {
+ var output = process.StandardOutput.ReadToEnd();
+ var error = process.StandardError.ReadToEnd();
+ process.WaitForExit(30000); // 30 second timeout
+
+ data["command"] = command;
+ data["exit_code"] = process.ExitCode;
+
+ if (!string.IsNullOrWhiteSpace(output))
+ {
+ data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
+ }
+
+ if (!string.IsNullOrWhiteSpace(error))
+ {
+ data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ data["error"] = ex.Message;
+ }
+
+ return data;
+ }
+
+ private Dictionary ExecuteFileRule(GatherRule rule)
+ {
+ var data = new Dictionary();
+ var filePath = rule.Target;
+
+ if (string.IsNullOrEmpty(filePath))
+ return data;
+
+ // Expand environment variables
+ filePath = Environment.ExpandEnvironmentVariables(filePath);
+
+ // Guard: only allow enrollment-relevant file paths
+ if (!GatherRuleGuards.IsFilePathAllowed(filePath))
+ return EmitSecurityWarning(rule, "file", filePath);
+
+ try
+ {
+ if (File.Exists(filePath))
+ {
+ var info = new FileInfo(filePath);
+ data["exists"] = true;
+ data["path"] = filePath;
+ data["size_bytes"] = info.Length;
+ data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
+ data["created"] = info.CreationTimeUtc.ToString("o");
+
+ // Read content if requested and file is small enough
+ string readContent;
+ if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
+ readContent == "true" && info.Length < 50000) // Max 50KB
+ {
+ // Read the last 4000 chars — most useful for log files where recent entries matter
+ const int maxChars = 4000;
+ string content;
+ bool truncated = false;
+ using (var reader = new StreamReader(filePath))
+ {
+ var full = reader.ReadToEnd();
+ if (full.Length > maxChars)
+ {
+ content = full.Substring(full.Length - maxChars);
+ truncated = true;
+ }
+ else
+ {
+ content = full;
+ }
+ }
+ data["content"] = truncated ? "(truncated) ..." + content : content;
+ }
+ }
+ else if (Directory.Exists(filePath))
+ {
+ data["exists"] = true;
+ data["is_directory"] = true;
+ data["path"] = filePath;
+
+ var files = Directory.GetFiles(filePath);
+ data["file_count"] = files.Length;
+ data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
+ }
+ else
+ {
+ data["exists"] = false;
+ data["path"] = filePath;
+ }
+ }
+ catch (Exception ex)
+ {
+ data["error"] = ex.Message;
+ }
+
+ return data;
+ }
+
+ private Dictionary ExecuteEventLogRule(GatherRule rule)
+ {
+ var data = new Dictionary();
+ var logName = rule.Target;
+
+ if (string.IsNullOrEmpty(logName))
+ return data;
+
+ try
+ {
+ int maxEntries = 10;
+ if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
+ {
+ int.TryParse(maxEntriesStr, out maxEntries);
+ maxEntries = Math.Min(maxEntries, 50); // Cap at 50
+ }
+
+ // Build XPath query with optional filters
+ var conditions = new List();
+
+ string sourceFilter = null;
+ if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
+ && !string.IsNullOrEmpty(sourceFilter))
+ {
+ conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
+ }
+
+ string eventIdFilter = null;
+ if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
+ && !string.IsNullOrEmpty(eventIdFilter))
+ {
+ conditions.Add($"EventID={eventIdFilter}");
+ }
+
+ string xpath = conditions.Count > 0
+ ? $"*[System[{string.Join(" and ", conditions)}]]"
+ : "*";
+
+ string messageFilter = null;
+ rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
+
+ var query = new EventLogQuery(logName, PathType.LogName, xpath)
+ {
+ ReverseDirection = true // newest first
+ };
+
+ data["log_name"] = logName;
+ var entries = new List();
+
+ using (var reader = new EventLogReader(query))
+ {
+ EventRecord record;
+ while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
+ {
+ using (record)
+ {
+ var message = "";
+ try { message = record.FormatDescription() ?? ""; }
+ catch { /* Some events lack formatting resources */ }
+
+ // Apply message filter (wildcard contains)
+ if (!string.IsNullOrEmpty(messageFilter))
+ {
+ var pattern = messageFilter.Trim('*');
+ if (string.IsNullOrEmpty(pattern) ||
+ message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
+ continue;
+ }
+
+ var level = "";
+ try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
+ catch { level = record.Level?.ToString() ?? "Unknown"; }
+
+ var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
+ entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
+ }
+ }
+ }
+
+ data["entries"] = string.Join("\n", entries);
+ data["entries_returned"] = entries.Count;
+ }
+ catch (Exception ex)
+ {
+ data["error"] = ex.Message;
+ }
+
+ return data;
+ }
+
+ private static string EscapeXPath(string value)
+ {
+ return value.Replace("'", "'");
+ }
+
+ private void ExecuteLogParserRule(GatherRule rule)
+ {
+ var filePath = rule.Target;
+ if (string.IsNullOrEmpty(filePath))
+ return;
+
+ filePath = Environment.ExpandEnvironmentVariables(filePath);
+
+ // Apply IME log path override if set
+ if (!string.IsNullOrEmpty(_imeLogPathOverride))
+ {
+ var fileName = Path.GetFileName(filePath);
+ filePath = Path.Combine(_imeLogPathOverride, fileName);
+ }
+
+ // Get parameters
+ string patternStr;
+ if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
+ string.IsNullOrEmpty(patternStr))
+ {
+ _logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
+ return;
+ }
+
+ string trackPositionStr;
+ bool trackPosition = true;
+ if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
+ bool.TryParse(trackPositionStr, out trackPosition);
+
+ string maxLinesStr;
+ int maxLines = 1000;
+ if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
+ int.TryParse(maxLinesStr, out maxLines);
+
+ Regex pattern;
+ try
+ {
+ pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
+ return;
+ }
+
+ if (!File.Exists(filePath))
+ {
+ _logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}");
+ return;
+ }
+
+ try
+ {
+ var fileInfo = new FileInfo(filePath);
+ long startPosition = trackPosition
+ ? _filePositionTracker.GetSafePosition(filePath, fileInfo.Length)
+ : 0;
+
+ // Nothing new to read
+ if (startPosition >= fileInfo.Length)
+ return;
+
+ int matchCount = 0;
+ int linesRead = 0;
+ long endPosition = startPosition;
+
+ using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+ {
+ stream.Seek(startPosition, SeekOrigin.Begin);
+
+ using (var reader = new StreamReader(stream, Encoding.UTF8))
+ {
+ string line;
+ while ((line = reader.ReadLine()) != null && linesRead < maxLines)
+ {
+ linesRead++;
+
+ CmTraceLogEntry entry;
+ if (!CmTraceLogParser.TryParseLine(line, out entry))
+ continue;
+
+ Match match;
+ try
+ {
+ match = pattern.Match(entry.Message);
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ continue;
+ }
+ if (!match.Success)
+ continue;
+
+ // Build data dictionary from named capture groups
+ var data = new Dictionary();
+ foreach (var groupName in pattern.GetGroupNames())
+ {
+ if (groupName == "0") continue; // Skip full match group
+ var group = match.Groups[groupName];
+ if (group.Success)
+ data[groupName] = group.Value;
+ }
+
+ // Add CMTrace metadata
+ data["logTimestamp"] = entry.Timestamp.ToString("o");
+ data["logComponent"] = entry.Component;
+ data["logType"] = entry.Type;
+ data["logMessage"] = TruncateMessage(entry.Message, 500);
+ data["ruleId"] = rule.RuleId;
+ data["ruleTitle"] = rule.Title;
+
+ var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
+ ? rule.OutputEventType
+ : "logparser_match";
+ var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = entry.Timestamp,
+ EventType = eventType,
+ Severity = severity,
+ Source = "GatherRuleExecutor",
+ Message = $"Gather: {rule.Title}",
+ Data = data
+ });
+
+ matchCount++;
+ }
+
+ // Capture the final stream position after reading
+ endPosition = stream.Position;
+ }
+ }
+
+ if (trackPosition)
+ _filePositionTracker.SetPosition(filePath, endPosition);
+
+ if (matchCount > 0)
+ _logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
+ }
+ }
+
+ private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
+ {
+ // If the rule specifies a severity, use it
+ if (!string.IsNullOrEmpty(ruleOverride))
+ {
+ switch (ruleOverride.ToLower())
+ {
+ case "debug": return EventSeverity.Debug;
+ case "info": return EventSeverity.Info;
+ case "warning": return EventSeverity.Warning;
+ case "error": return EventSeverity.Error;
+ case "critical": return EventSeverity.Critical;
+ }
+ }
+
+ // Otherwise derive from CMTrace log type
+ switch (cmTraceType)
+ {
+ case 2: return EventSeverity.Warning;
+ case 3: return EventSeverity.Error;
+ default: return EventSeverity.Info;
+ }
+ }
+
+ private static string TruncateMessage(string message, int maxLength)
+ {
+ if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
+ return message;
+ return message.Substring(0, maxLength) + "...";
+ }
+
+ ///
+ /// Emits a security_warning event and returns an empty result dictionary.
+ /// Called when a gather rule targets a path/query not on the allowlist.
+ ///
+ private Dictionary EmitSecurityWarning(GatherRule rule, string collectorType, string target)
+ {
+ _logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
+
+ var data = new Dictionary
+ {
+ ["blocked"] = true,
+ ["reason"] = $"{collectorType} target not on allowlist",
+ ["target"] = target,
+ ["ruleId"] = rule.RuleId,
+ };
+
+ _onEventCollected(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "security_warning",
+ Severity = EventSeverity.Warning,
+ Source = "GatherRuleExecutor",
+ Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
+ Data = data
+ });
+
+ return new Dictionary();
+ }
+
+ private EventSeverity ParseSeverity(string severity)
+ {
+ if (string.IsNullOrEmpty(severity))
+ return EventSeverity.Info;
+
+ switch (severity.ToLower())
+ {
+ case "debug": return EventSeverity.Debug;
+ case "info": return EventSeverity.Info;
+ case "warning": return EventSeverity.Warning;
+ case "error": return EventSeverity.Error;
+ case "critical": return EventSeverity.Critical;
+ default: return EventSeverity.Info;
+ }
+ }
+
+ private void StopAllTimers()
+ {
+ foreach (var timer in _intervalTimers.Values)
+ {
+ timer.Dispose();
+ }
+ _intervalTimers.Clear();
+ }
+
+ ///
+ /// Blocks until all startup rules that were queued by the most recent
+ /// call have finished executing, or the timeout elapses.
+ /// Returns true if all rules completed within the timeout; false if timed out.
+ ///
+ public bool WaitForStartupRules(int timeoutSeconds = 120)
+ {
+ var latch = _startupRulesLatch;
+ if (latch == null)
+ return true; // no startup rules were queued
+
+ _logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
+ var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
+ _logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
+ return completed;
+ }
+
+ public void Dispose()
+ {
+ StopAllTimers();
+ _startupRulesLatch?.Dispose();
+ _startupRulesLatch = null;
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs
index 3a0adffb..715c5e59 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// Executes gather rules received from the backend API
/// Supports registry, eventlog, wmi, file, command_allowlisted, and logparser collector types
///
- public class GatherRuleExecutor : IDisposable
+ public partial class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly string _sessionId;
@@ -197,696 +197,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
}
}
- private Dictionary ExecuteRegistryRule(GatherRule rule)
- {
- var data = new Dictionary();
- var path = rule.Target;
-
- if (string.IsNullOrEmpty(path))
- return data;
-
- // Determine hive
- RegistryKey hive = null;
- string subPath = path;
-
- if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
- path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
- {
- hive = Registry.LocalMachine;
- subPath = path.Substring(path.IndexOf('\\') + 1);
- }
- else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
- path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
- {
- hive = Registry.CurrentUser;
- subPath = path.Substring(path.IndexOf('\\') + 1);
- }
- else
- {
- // Default to HKLM
- hive = Registry.LocalMachine;
- }
-
- // Guard: only allow enrollment-relevant registry paths
- if (!GatherRuleGuards.IsRegistryPathAllowed(subPath))
- return EmitSecurityWarning(rule, "registry", path);
-
- // Determine explicit valueName from parameters
- string explicitValueName = null;
- rule.Parameters?.TryGetValue("valueName", out explicitValueName);
-
- try
- {
- using (var key = hive.OpenSubKey(subPath, false))
- {
- if (key == null)
- {
- // The key doesn't exist as a sub-key. If no explicit valueName was given,
- // check whether the last path segment is a value name in the parent key.
- // This handles the common case where the user specifies the full
- // "HKLM\...\KeyName\ValueName" path directly in Target.
- if (string.IsNullOrEmpty(explicitValueName))
- {
- var lastBackslash = subPath.LastIndexOf('\\');
- if (lastBackslash > 0)
- {
- var parentSubPath = subPath.Substring(0, lastBackslash);
- var inferredValueName = subPath.Substring(lastBackslash + 1);
-
- using (var parentKey = hive.OpenSubKey(parentSubPath, false))
- {
- if (parentKey != null)
- {
- var valueNames = parentKey.GetValueNames();
- if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
- {
- var value = parentKey.GetValue(inferredValueName);
- data["exists"] = true;
- data["path"] = path.Substring(0, path.LastIndexOf('\\'));
- data[inferredValueName] = value?.ToString();
- return data;
- }
- }
- }
- }
- }
-
- data["exists"] = false;
- data["path"] = path;
- return data;
- }
-
- data["exists"] = true;
- data["path"] = path;
-
- // Read specific value if specified in parameters
- if (!string.IsNullOrEmpty(explicitValueName))
- {
- var value = key.GetValue(explicitValueName);
- data[explicitValueName] = value?.ToString();
- }
- else
- {
- // Read all values
- foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
- {
- try
- {
- var value = key.GetValue(name);
- var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
- data[displayName] = value?.ToString();
- }
- catch { }
- }
- }
- }
- }
- catch (Exception ex)
- {
- data["error"] = ex.Message;
- }
-
- return data;
- }
-
- private Dictionary ExecuteWmiRule(GatherRule rule)
- {
- var data = new Dictionary();
- var query = rule.Target;
-
- if (string.IsNullOrEmpty(query))
- return data;
-
- // Guard: only allow known-safe WMI classes
- if (!GatherRuleGuards.IsWmiQueryAllowed(query))
- return EmitSecurityWarning(rule, "wmi", query);
-
- try
- {
- using (var searcher = new ManagementObjectSearcher(query))
- {
- int index = 0;
- foreach (ManagementObject obj in searcher.Get())
- {
- var item = new Dictionary();
- foreach (var prop in obj.Properties)
- {
- try
- {
- item[prop.Name] = prop.Value?.ToString();
- }
- catch { }
- }
-
- if (item.Count > 0)
- {
- // For single result, flatten; for multiple, use indexed keys
- if (index == 0)
- {
- foreach (var kvp in item)
- data[kvp.Key] = kvp.Value;
- }
- else
- {
- data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
- }
- }
- index++;
-
- if (index >= 20) break; // Limit results
- }
- }
- }
- catch (Exception ex)
- {
- data["error"] = ex.Message;
- }
-
- return data;
- }
-
- private Dictionary ExecuteCommandRule(GatherRule rule)
- {
- var data = new Dictionary();
- var command = rule.Target;
-
- if (string.IsNullOrEmpty(command))
- return data;
-
- // SECURITY: Check command against allowlist
- if (!GatherRuleGuards.IsCommandAllowed(command))
- {
- _logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
- data["blocked"] = true;
- data["reason"] = "Command not on allowlist";
- data["command"] = command;
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "security_warning",
- Severity = EventSeverity.Warning,
- Source = "GatherRuleExecutor",
- Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
- Data = data
- });
-
- return data;
- }
-
- try
- {
- var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
- !command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
- !command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
- !command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
- !command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
-
- ProcessStartInfo psi;
- if (isPowerShell)
- {
- // Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
- var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
- psi = new ProcessStartInfo
- {
- FileName = "powershell.exe",
- Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
- }
- else
- {
- psi = new ProcessStartInfo
- {
- FileName = "cmd.exe",
- Arguments = $"/c {command}",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
- }
-
- using (var process = Process.Start(psi))
- {
- var output = process.StandardOutput.ReadToEnd();
- var error = process.StandardError.ReadToEnd();
- process.WaitForExit(30000); // 30 second timeout
-
- data["command"] = command;
- data["exit_code"] = process.ExitCode;
-
- if (!string.IsNullOrWhiteSpace(output))
- {
- data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
- }
-
- if (!string.IsNullOrWhiteSpace(error))
- {
- data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
- }
- }
- }
- catch (Exception ex)
- {
- data["error"] = ex.Message;
- }
-
- return data;
- }
-
- private Dictionary ExecuteFileRule(GatherRule rule)
- {
- var data = new Dictionary();
- var filePath = rule.Target;
-
- if (string.IsNullOrEmpty(filePath))
- return data;
-
- // Expand environment variables
- filePath = Environment.ExpandEnvironmentVariables(filePath);
-
- // Guard: only allow enrollment-relevant file paths
- if (!GatherRuleGuards.IsFilePathAllowed(filePath))
- return EmitSecurityWarning(rule, "file", filePath);
-
- try
- {
- if (File.Exists(filePath))
- {
- var info = new FileInfo(filePath);
- data["exists"] = true;
- data["path"] = filePath;
- data["size_bytes"] = info.Length;
- data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
- data["created"] = info.CreationTimeUtc.ToString("o");
-
- // Read content if requested and file is small enough
- string readContent;
- if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
- readContent == "true" && info.Length < 50000) // Max 50KB
- {
- // Read the last 4000 chars — most useful for log files where recent entries matter
- const int maxChars = 4000;
- string content;
- bool truncated = false;
- using (var reader = new StreamReader(filePath))
- {
- var full = reader.ReadToEnd();
- if (full.Length > maxChars)
- {
- content = full.Substring(full.Length - maxChars);
- truncated = true;
- }
- else
- {
- content = full;
- }
- }
- data["content"] = truncated ? "(truncated) ..." + content : content;
- }
- }
- else if (Directory.Exists(filePath))
- {
- data["exists"] = true;
- data["is_directory"] = true;
- data["path"] = filePath;
-
- var files = Directory.GetFiles(filePath);
- data["file_count"] = files.Length;
- data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
- }
- else
- {
- data["exists"] = false;
- data["path"] = filePath;
- }
- }
- catch (Exception ex)
- {
- data["error"] = ex.Message;
- }
-
- return data;
- }
-
- private Dictionary ExecuteEventLogRule(GatherRule rule)
- {
- var data = new Dictionary();
- var logName = rule.Target;
-
- if (string.IsNullOrEmpty(logName))
- return data;
-
- try
- {
- int maxEntries = 10;
- if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
- {
- int.TryParse(maxEntriesStr, out maxEntries);
- maxEntries = Math.Min(maxEntries, 50); // Cap at 50
- }
-
- // Build XPath query with optional filters
- var conditions = new List();
-
- string sourceFilter = null;
- if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
- && !string.IsNullOrEmpty(sourceFilter))
- {
- conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
- }
-
- string eventIdFilter = null;
- if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
- && !string.IsNullOrEmpty(eventIdFilter))
- {
- conditions.Add($"EventID={eventIdFilter}");
- }
-
- string xpath = conditions.Count > 0
- ? $"*[System[{string.Join(" and ", conditions)}]]"
- : "*";
-
- string messageFilter = null;
- rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
-
- var query = new EventLogQuery(logName, PathType.LogName, xpath)
- {
- ReverseDirection = true // newest first
- };
-
- data["log_name"] = logName;
- var entries = new List();
-
- using (var reader = new EventLogReader(query))
- {
- EventRecord record;
- while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
- {
- using (record)
- {
- var message = "";
- try { message = record.FormatDescription() ?? ""; }
- catch { /* Some events lack formatting resources */ }
-
- // Apply message filter (wildcard contains)
- if (!string.IsNullOrEmpty(messageFilter))
- {
- var pattern = messageFilter.Trim('*');
- if (string.IsNullOrEmpty(pattern) ||
- message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
- continue;
- }
-
- var level = "";
- try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
- catch { level = record.Level?.ToString() ?? "Unknown"; }
-
- var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
- entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
- }
- }
- }
-
- data["entries"] = string.Join("\n", entries);
- data["entries_returned"] = entries.Count;
- }
- catch (Exception ex)
- {
- data["error"] = ex.Message;
- }
-
- return data;
- }
-
- private static string EscapeXPath(string value)
- {
- return value.Replace("'", "'");
- }
-
- private void ExecuteLogParserRule(GatherRule rule)
- {
- var filePath = rule.Target;
- if (string.IsNullOrEmpty(filePath))
- return;
-
- filePath = Environment.ExpandEnvironmentVariables(filePath);
-
- // Apply IME log path override if set
- if (!string.IsNullOrEmpty(_imeLogPathOverride))
- {
- var fileName = Path.GetFileName(filePath);
- filePath = Path.Combine(_imeLogPathOverride, fileName);
- }
-
- // Get parameters
- string patternStr;
- if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
- string.IsNullOrEmpty(patternStr))
- {
- _logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
- return;
- }
-
- string trackPositionStr;
- bool trackPosition = true;
- if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
- bool.TryParse(trackPositionStr, out trackPosition);
-
- string maxLinesStr;
- int maxLines = 1000;
- if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
- int.TryParse(maxLinesStr, out maxLines);
-
- Regex pattern;
- try
- {
- pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
- }
- catch (Exception ex)
- {
- _logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
- return;
- }
-
- if (!File.Exists(filePath))
- {
- _logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}");
- return;
- }
-
- try
- {
- var fileInfo = new FileInfo(filePath);
- long startPosition = trackPosition
- ? _filePositionTracker.GetSafePosition(filePath, fileInfo.Length)
- : 0;
-
- // Nothing new to read
- if (startPosition >= fileInfo.Length)
- return;
-
- int matchCount = 0;
- int linesRead = 0;
- long endPosition = startPosition;
-
- using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
- {
- stream.Seek(startPosition, SeekOrigin.Begin);
-
- using (var reader = new StreamReader(stream, Encoding.UTF8))
- {
- string line;
- while ((line = reader.ReadLine()) != null && linesRead < maxLines)
- {
- linesRead++;
-
- CmTraceLogEntry entry;
- if (!CmTraceLogParser.TryParseLine(line, out entry))
- continue;
-
- Match match;
- try
- {
- match = pattern.Match(entry.Message);
- }
- catch (RegexMatchTimeoutException)
- {
- continue;
- }
- if (!match.Success)
- continue;
-
- // Build data dictionary from named capture groups
- var data = new Dictionary();
- foreach (var groupName in pattern.GetGroupNames())
- {
- if (groupName == "0") continue; // Skip full match group
- var group = match.Groups[groupName];
- if (group.Success)
- data[groupName] = group.Value;
- }
-
- // Add CMTrace metadata
- data["logTimestamp"] = entry.Timestamp.ToString("o");
- data["logComponent"] = entry.Component;
- data["logType"] = entry.Type;
- data["logMessage"] = TruncateMessage(entry.Message, 500);
- data["ruleId"] = rule.RuleId;
- data["ruleTitle"] = rule.Title;
-
- var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
- ? rule.OutputEventType
- : "logparser_match";
- var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- Timestamp = entry.Timestamp,
- EventType = eventType,
- Severity = severity,
- Source = "GatherRuleExecutor",
- Message = $"Gather: {rule.Title}",
- Data = data
- });
-
- matchCount++;
- }
-
- // Capture the final stream position after reading
- endPosition = stream.Position;
- }
- }
-
- if (trackPosition)
- _filePositionTracker.SetPosition(filePath, endPosition);
-
- if (matchCount > 0)
- _logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
- }
- }
-
- private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
- {
- // If the rule specifies a severity, use it
- if (!string.IsNullOrEmpty(ruleOverride))
- {
- switch (ruleOverride.ToLower())
- {
- case "debug": return EventSeverity.Debug;
- case "info": return EventSeverity.Info;
- case "warning": return EventSeverity.Warning;
- case "error": return EventSeverity.Error;
- case "critical": return EventSeverity.Critical;
- }
- }
-
- // Otherwise derive from CMTrace log type
- switch (cmTraceType)
- {
- case 2: return EventSeverity.Warning;
- case 3: return EventSeverity.Error;
- default: return EventSeverity.Info;
- }
- }
-
- private static string TruncateMessage(string message, int maxLength)
- {
- if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
- return message;
- return message.Substring(0, maxLength) + "...";
- }
-
- ///
- /// Emits a security_warning event and returns an empty result dictionary.
- /// Called when a gather rule targets a path/query not on the allowlist.
- ///
- private Dictionary EmitSecurityWarning(GatherRule rule, string collectorType, string target)
- {
- _logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
-
- var data = new Dictionary
- {
- ["blocked"] = true,
- ["reason"] = $"{collectorType} target not on allowlist",
- ["target"] = target,
- ["ruleId"] = rule.RuleId,
- };
-
- _onEventCollected(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "security_warning",
- Severity = EventSeverity.Warning,
- Source = "GatherRuleExecutor",
- Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
- Data = data
- });
-
- return new Dictionary();
- }
-
- private EventSeverity ParseSeverity(string severity)
- {
- if (string.IsNullOrEmpty(severity))
- return EventSeverity.Info;
-
- switch (severity.ToLower())
- {
- case "debug": return EventSeverity.Debug;
- case "info": return EventSeverity.Info;
- case "warning": return EventSeverity.Warning;
- case "error": return EventSeverity.Error;
- case "critical": return EventSeverity.Critical;
- default: return EventSeverity.Info;
- }
- }
-
- private void StopAllTimers()
- {
- foreach (var timer in _intervalTimers.Values)
- {
- timer.Dispose();
- }
- _intervalTimers.Clear();
- }
-
- ///
- /// Blocks until all startup rules that were queued by the most recent
- /// call have finished executing, or the timeout elapses.
- /// Returns true if all rules completed within the timeout; false if timed out.
- ///
- public bool WaitForStartupRules(int timeoutSeconds = 120)
- {
- var latch = _startupRulesLatch;
- if (latch == null)
- return true; // no startup rules were queued
-
- _logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
- var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
- _logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
- return completed;
- }
-
- public void Dispose()
- {
- StopAllTimers();
- _startupRulesLatch?.Dispose();
- _startupRulesLatch = null;
- }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs
new file mode 100644
index 00000000..9584e225
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs
@@ -0,0 +1,641 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Analyzers;
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using AutopilotMonitor.Agent.Core.Monitoring.Network;
+using AutopilotMonitor.Agent.Core.Monitoring.Replay;
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Core
+{
+ ///
+ /// Partial: Collector lifecycle management (start/stop optional collectors, idle timeout,
+ /// max lifetime timer, gather rules, analyzers, session registration).
+ ///
+ public partial class MonitoringService
+ {
+ private void StartOptionalCollectors()
+ {
+ var config = _remoteConfigService?.CurrentConfig;
+ var collectors = config?.Collectors;
+
+ _logger.Info("Starting collectors based on remote config");
+
+ try
+ {
+ // PerformanceCollector is always on (feeds UI chart)
+ var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
+ _performanceCollector = new PerformanceCollector(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _logger,
+ perfInterval
+ );
+ _performanceCollector.Start();
+
+ // AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint
+ if (collectors?.EnableAgentSelfMetrics != false)
+ {
+ var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
+ _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _apiClient.NetworkMetrics,
+ _logger,
+ _agentVersion,
+ selfMetricsInterval
+ );
+ _agentSelfMetricsCollector.Start();
+ }
+
+ // Collector idle timeout — stop periodic collectors when enrollment goes quiet
+ _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
+ _lastRealEventTime = DateTime.UtcNow;
+ _collectorsIdleStopped = false;
+ if (_collectorIdleTimeoutMinutes > 0)
+ {
+ _idleCheckTimer = new Timer(
+ IdleCheckCallback,
+ null,
+ TimeSpan.FromSeconds(60),
+ TimeSpan.FromSeconds(60)
+ );
+ _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
+ }
+ else
+ {
+ _logger.Info("Collector idle timeout disabled (0)");
+ }
+
+ // EnrollmentTracker: smart enrollment tracking with IME log parsing
+ // (replaces DownloadProgressCollector + EspUiStateCollector)
+ // Skip when log replay is active - LogReplayService starts its own EnrollmentTracker
+ if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
+ {
+ var imeLogPatterns = config?.ImeLogPatterns;
+ var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
+ ? null
+ : Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
+
+ _enrollmentTracker = new EnrollmentTracker(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _logger,
+ imeLogPatterns,
+ _configuration.ImeLogPathOverride,
+ imeMatchLogPath: imeMatchLogPath,
+ espAndHelloTracker: _espAndHelloTracker,
+ isBootstrapMode: _configuration.UseBootstrapTokenAuth
+ );
+ _enrollmentTracker.Start();
+ }
+
+ // Desktop arrival detector — detects real user desktop for no-ESP completion
+ _desktopArrivalDetector = new DesktopArrivalDetector(_logger);
+ _desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
+ _desktopArrivalDetector.Start();
+
+ // Agent max lifetime safety net — prevents zombie agents
+ var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
+ if (maxLifetimeMinutes > 0)
+ {
+ _maxLifetimeTimer = new Timer(
+ MaxLifetimeTimerCallback,
+ null,
+ TimeSpan.FromMinutes(maxLifetimeMinutes),
+ Timeout.InfiniteTimeSpan);
+ _logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
+ }
+ else
+ {
+ _logger.Info("Agent max lifetime timer disabled (0)");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error starting optional collectors", ex);
+ }
+ }
+
+ ///
+ /// Stops all optional collectors
+ ///
+ private void StopOptionalCollectors()
+ {
+ _idleCheckTimer?.Dispose();
+ _idleCheckTimer = null;
+
+ _maxLifetimeTimer?.Dispose();
+ _maxLifetimeTimer = null;
+
+ if (_desktopArrivalDetector != null)
+ {
+ _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
+ _desktopArrivalDetector.Dispose();
+ _desktopArrivalDetector = null;
+ }
+
+ _performanceCollector?.Stop();
+ _performanceCollector?.Dispose();
+ _performanceCollector = null;
+
+ _agentSelfMetricsCollector?.Stop();
+ _agentSelfMetricsCollector?.Dispose();
+ _agentSelfMetricsCollector = null;
+
+ _enrollmentTracker?.Stop();
+ _enrollmentTracker?.Dispose();
+ _enrollmentTracker = null;
+ }
+
+ ///
+ /// Fires when the agent max lifetime expires. Emits enrollment_failed with failureType "agent_timeout".
+ /// The normal shutdown path (EmitEvent → enrollment_failed) handles everything from here.
+ ///
+ private void MaxLifetimeTimerCallback(object state)
+ {
+ if (_enrollmentTerminalEventSeen)
+ {
+ _logger.Debug("Max lifetime timer fired but terminal event already seen — ignoring");
+ return;
+ }
+
+ var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
+ _logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
+
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "enrollment_failed",
+ Severity = EventSeverity.Error,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
+ Data = new Dictionary
+ {
+ { "failureType", "agent_timeout" },
+ { "failureSource", "max_lifetime_timer" },
+ { "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
+ }
+ });
+ }
+
+ ///
+ /// Fires when DesktopArrivalDetector detects explorer.exe under a real user.
+ /// Emits desktop_arrived event for the timeline and notifies EnrollmentTracker.
+ ///
+ private void OnDesktopArrived(object sender, EventArgs e)
+ {
+ if (_enrollmentTerminalEventSeen)
+ {
+ _logger.Debug("Desktop arrived but terminal event already seen — ignoring");
+ return;
+ }
+
+ // Emit informational event for the timeline (shows when user desktop appeared)
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "desktop_arrived",
+ Severity = EventSeverity.Info,
+ Source = "DesktopArrivalDetector",
+ Phase = EnrollmentPhase.Unknown,
+ Message = "User desktop detected (explorer.exe under real user)"
+ });
+
+ // Notify EnrollmentTracker — it decides whether this triggers completion
+ _enrollmentTracker?.NotifyDesktopArrived();
+ }
+
+ ///
+ /// Returns true for events generated by periodic timers (not real enrollment activity)
+ ///
+ private static bool IsPeriodicEvent(string eventType)
+ {
+ return eventType == "performance_snapshot" ||
+ eventType == "agent_metrics_snapshot" ||
+ eventType == "performance_collector_stopped" ||
+ eventType == "agent_metrics_collector_stopped";
+ }
+
+ ///
+ /// Periodic check (every 60s) whether enrollment has gone idle.
+ /// When no real event has arrived within the configured timeout window,
+ /// periodic collectors are stopped to prevent session bloat.
+ ///
+ private void IdleCheckCallback(object state)
+ {
+ if (_collectorsIdleStopped)
+ return;
+
+ var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
+ if (idleMinutes < _collectorIdleTimeoutMinutes)
+ return;
+
+ _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
+
+ // Stop PerformanceCollector
+ if (_performanceCollector != null)
+ {
+ _performanceCollector.Stop();
+ _performanceCollector.Dispose();
+ _performanceCollector = null;
+
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "performance_collector_stopped",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
+ Data = new Dictionary
+ {
+ { "reason", "idle_timeout" },
+ { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
+ { "idleMinutes", Math.Round(idleMinutes, 1) }
+ }
+ });
+ }
+
+ // Stop AgentSelfMetricsCollector
+ if (_agentSelfMetricsCollector != null)
+ {
+ _agentSelfMetricsCollector.Stop();
+ _agentSelfMetricsCollector.Dispose();
+ _agentSelfMetricsCollector = null;
+
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "agent_metrics_collector_stopped",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
+ Data = new Dictionary
+ {
+ { "reason", "idle_timeout" },
+ { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
+ { "idleMinutes", Math.Round(idleMinutes, 1) }
+ }
+ });
+ }
+
+ _collectorsIdleStopped = true;
+
+ // Stop the idle check timer — it will be restarted if collectors resume
+ _idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+
+ ///
+ /// Restarts periodic collectors after they were stopped due to idle timeout.
+ /// Called when new real enrollment activity is detected.
+ ///
+ private void RestartPeriodicCollectors()
+ {
+ var config = _remoteConfigService?.CurrentConfig;
+ var collectors = config?.Collectors;
+
+ _logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
+
+ try
+ {
+ var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
+ _performanceCollector = new PerformanceCollector(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _logger,
+ perfInterval
+ );
+ _performanceCollector.Start();
+
+ if (collectors?.EnableAgentSelfMetrics != false)
+ {
+ var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
+ _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _apiClient.NetworkMetrics,
+ _logger,
+ _agentVersion,
+ selfMetricsInterval
+ );
+ _agentSelfMetricsCollector.Start();
+ }
+
+ // Reset idle tracking and restart the check timer
+ _lastRealEventTime = DateTime.UtcNow;
+ _collectorsIdleStopped = false;
+ if (_collectorIdleTimeoutMinutes > 0)
+ {
+ _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error restarting periodic collectors", ex);
+ }
+ }
+
+ ///
+ /// Starts the gather rule executor with rules from remote config
+ ///
+ private void StartGatherRuleExecutor()
+ {
+ var config = _remoteConfigService?.CurrentConfig;
+ if (config?.GatherRules == null || config.GatherRules.Count == 0)
+ {
+ _logger.Info("No gather rules to execute");
+ return;
+ }
+
+ try
+ {
+ _gatherRuleExecutor = new GatherRuleExecutor(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _logger,
+ _configuration.ImeLogPathOverride
+ );
+ _gatherRuleExecutor.UpdateRules(config.GatherRules);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error starting gather rule executor", ex);
+ }
+ }
+
+ ///
+ /// Initializes agent analyzers from remote config.
+ /// Called once during Start(), after all collectors are started.
+ ///
+ private void InitializeAnalyzers()
+ {
+ _analyzers.Clear();
+
+ var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
+
+ if (analyzerConfig.EnableLocalAdminAnalyzer)
+ {
+ _analyzers.Add(new LocalAdminAnalyzer(
+ _configuration.SessionId,
+ _configuration.TenantId,
+ EmitEvent,
+ _logger,
+ analyzerConfig.LocalAdminAllowedAccounts
+ ));
+ _logger.Info("LocalAdminAnalyzer registered");
+ }
+ else
+ {
+ _logger.Info("LocalAdminAnalyzer disabled by remote config");
+ }
+
+ _logger.Info($"Analyzers initialized: {_analyzers.Count} active");
+ }
+
+ ///
+ /// Runs all registered analyzers at agent startup — asynchronously on the ThreadPool
+ /// so they do not block or delay the agent start sequence.
+ /// Emitted events will be picked up by the spool watcher and uploaded normally.
+ ///
+ private void RunStartupAnalyzers()
+ {
+ if (_analyzers.Count == 0)
+ return;
+
+ // Capture the list snapshot — _analyzers is not modified after Start()
+ var analyzers = new List(_analyzers);
+
+ _logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
+
+ Task.Run(() =>
+ {
+ foreach (var analyzer in analyzers)
+ {
+ try
+ {
+ analyzer.AnalyzeAtStartup();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
+ }
+ }
+ });
+ }
+
+ ///
+ /// Runs all registered analyzers at shutdown / enrollment completion.
+ /// Emits end-state findings for delta detection against the startup results.
+ ///
+ private void RunShutdownAnalyzers()
+ {
+ if (_analyzers.Count == 0)
+ return;
+
+ _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s)");
+
+ foreach (var analyzer in _analyzers)
+ {
+ try
+ {
+ analyzer.AnalyzeAtShutdown();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
+ }
+ }
+ }
+
+ ///
+ /// Registers the session with the backend. Retries with exponential backoff
+ /// to handle network not being ready during OOBE boot (WhiteGlove Part 2).
+ ///
+ private async Task RegisterSessionAsync()
+ {
+ const int maxAttempts = 5;
+
+ var registration = new SessionRegistration
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ SerialNumber = DeviceInfoProvider.GetSerialNumber(),
+ Manufacturer = DeviceInfoProvider.GetManufacturer(),
+ Model = DeviceInfoProvider.GetModel(),
+ DeviceName = Environment.MachineName,
+ OsBuild = Environment.OSVersion.Version.ToString(),
+ OsEdition = DeviceInfoProvider.GetOsEdition(),
+ OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
+ StartedAt = DateTime.UtcNow,
+ AgentVersion = _agentVersion,
+ EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(),
+ IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(),
+ IsUserDriven = true
+ };
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ try
+ {
+ _logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})");
+
+ var response = await _apiClient.RegisterSessionAsync(registration);
+
+ if (response.Success)
+ {
+ _logger.Info($"Session registered successfully: {response.SessionId}");
+ return;
+ }
+
+ _logger.Warning($"Session registration failed: {response.Message}");
+ }
+ catch (BackendAuthException ex)
+ {
+ _logger.Error($"Session registration authentication failed: {ex.Message}");
+ HandleAuthFailure();
+ return; // Auth failure is not retryable
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex);
+
+ if (attempt == maxAttempts)
+ {
+ // Report to emergency channel on final failure
+ _ = _emergencyReporter.TrySendAsync(
+ AgentErrorType.RegisterSessionFailed,
+ ex.Message);
+ return;
+ }
+ }
+
+ // Exponential backoff: 2s, 4s, 8s, 16s
+ var delaySeconds = (int)Math.Pow(2, attempt);
+ _logger.Info($"Retrying session registration in {delaySeconds}s");
+ await Task.Delay(delaySeconds * 1000);
+ }
+ }
+
+ ///
+ /// Stops the monitoring service
+ ///
+ public void Stop()
+ {
+ _logger.Info("Stopping monitoring service");
+
+ // Stop FileSystemWatcher
+ _spool.StopWatching();
+
+ // Stop event collectors
+ StopEventCollectors();
+
+ // Run shutdown analyzers before final event (captures end-state for delta detection)
+ RunShutdownAnalyzers();
+
+ // Emit final event
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "agent_stopped",
+ Severity = EventSeverity.Info,
+ Source = "Agent",
+ Phase = _lastPhase ?? EnrollmentPhase.Unknown,
+ Message = "Autopilot Monitor Agent stopped"
+ });
+
+ // Final upload attempt
+ UploadEventsAsync().Wait(TimeSpan.FromSeconds(10));
+
+ _logger.Info("Monitoring service stopped");
+
+ // Unblock WaitForCompletion() in background/SYSTEM mode
+ _completionEvent.Set();
+ }
+
+ ///
+ /// Blocks the calling thread until the service stops (used when running as Scheduled Task
+ /// under SYSTEM where there is no interactive Console.ReadLine() to keep the process alive).
+ ///
+ public void WaitForCompletion()
+ {
+ _completionEvent.Wait();
+ }
+
+ ///
+ /// Triggers cleanup without running enrollment monitoring.
+ /// Used when enrollment complete marker is detected on startup (cleanup retry).
+ ///
+ public void TriggerCleanup()
+ {
+ _logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring");
+
+ if (_configuration.SelfDestructOnComplete)
+ {
+ _cleanupService.ExecuteSelfDestruct();
+ }
+ else
+ {
+ _logger.Info("SelfDestructOnComplete is disabled - nothing to clean up");
+ }
+ }
+
+ ///
+ /// Stops all event collection components
+ ///
+ private void StopEventCollectors()
+ {
+ _logger.Info("Stopping event collectors");
+
+ try
+ {
+ _espAndHelloTracker?.Stop();
+ _espAndHelloTracker?.Dispose();
+
+ _logReplay?.Stop();
+ _logReplay?.Dispose();
+
+ // Stop optional collectors
+ StopOptionalCollectors();
+
+ // Stop gather rule executor
+ _gatherRuleExecutor?.Dispose();
+ _gatherRuleExecutor = null;
+
+ _logger.Info("Event collectors stopped");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error stopping event collectors", ex);
+ }
+ }
+
+ ///
+ /// Emits an event (adds to spool)
+ ///
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs
new file mode 100644
index 00000000..22076c96
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs
@@ -0,0 +1,565 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using AutopilotMonitor.Agent.Core.Monitoring.Network;
+using AutopilotMonitor.Agent.Core.Monitoring.Tracking;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Core
+{
+ ///
+ /// Partial: Event emission, upload orchestration, auth failure handling,
+ /// enrollment/WhiteGlove completion, and diagnostics upload.
+ ///
+ public partial class MonitoringService
+ {
+ public void EmitEvent(EnrollmentEvent evt)
+ {
+ evt.Sequence = Interlocked.Increment(ref _eventSequence);
+ _spool.Add(evt);
+ _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}");
+
+ // Track real enrollment activity for idle timeout
+ if (!string.IsNullOrEmpty(evt.EventType) && !IsPeriodicEvent(evt.EventType))
+ {
+ _lastRealEventTime = DateTime.UtcNow;
+
+ // Restart periodic collectors if they were stopped due to idle
+ if (_collectorsIdleStopped)
+ {
+ RestartPeriodicCollectors();
+ }
+ }
+
+ // Persist sequence periodically (every 50 events) + always on critical events
+ if (evt.Sequence % 50 == 0 ||
+ evt.EventType == "whiteglove_complete" ||
+ evt.EventType == "enrollment_complete" ||
+ evt.EventType == "enrollment_failed")
+ {
+ _sessionPersistence.SaveSequence(evt.Sequence);
+ }
+
+ // Cancel max lifetime timer on terminal events
+ if (evt.EventType == "whiteglove_complete" ||
+ evt.EventType == "enrollment_complete" ||
+ evt.EventType == "enrollment_failed")
+ {
+ _enrollmentTerminalEventSeen = true;
+ _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+
+ // Check if this is a phase transition
+ bool isPhaseTransition = false;
+ if (evt.Phase != _lastPhase)
+ {
+ _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
+ _lastPhase = evt.Phase;
+ isPhaseTransition = true;
+
+ // Notify gather rule executor of phase change
+ try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { }
+ }
+
+ // Notify gather rule executor of event type (for on_event triggers)
+ if (!string.IsNullOrEmpty(evt.EventType))
+ {
+ try { _gatherRuleExecutor?.OnEvent(evt.EventType); } catch { }
+ }
+
+ // Check for WhiteGlove completion — agent exits gracefully, session stays open
+ if (evt.EventType == "whiteglove_complete")
+ {
+ _logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence.");
+
+ // Stop performance collection immediately (no value while device is off)
+ _performanceCollector?.Stop();
+ _performanceCollector?.Dispose();
+ _performanceCollector = null;
+
+ // Stop all event collectors and the spool watcher — no new events after this point
+ StopEventCollectors();
+ _spool.StopWatching();
+
+ // Stop upload timers — we drive uploads manually from here
+ _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+
+ Task.Run(() => HandleWhiteGloveComplete());
+
+ return; // Don't continue with normal event processing
+ }
+
+ // Check for enrollment completion events
+ if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed")
+ {
+ var enrollmentSucceeded = evt.EventType == "enrollment_complete";
+ _logger.Info($"Enrollment completion detected: {evt.EventType}");
+
+ // Delete session ID so a new session will be created on next enrollment
+ DeleteSessionId();
+
+ // Stop ALL collectors to minimize system impact
+ _logger.Info("Stopping all collectors after enrollment completion...");
+ StopEventCollectors();
+
+ // Stop upload timers - no more periodic uploads needed
+ _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+
+ // Final upload to flush any remaining events
+ Task.Run(async () =>
+ {
+ try
+ {
+ await UploadEventsAsync();
+ _logger.Info("Final event upload after enrollment completion done");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}");
+ }
+ });
+
+ // Trigger self-destruct or reboot if configured
+ if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete)
+ {
+ Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded));
+ return; // Don't continue with normal event processing
+ }
+
+ // No self-destruct/reboot — still upload diagnostics if configured
+ Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded));
+ }
+
+ // Immediate upload for:
+ // 1. Critical events (errors) - for troubleshooting
+ // 2. Phase transitions (start/end) - for real-time phase tracking in UI
+ // 3. Events with "phase" in EventType - explicit phase-related events
+ // 4. App download/install events - for real-time download progress UI updates
+ var isAppEvent = evt.EventType?.StartsWith("app_", StringComparison.OrdinalIgnoreCase) == true;
+
+ if (evt.Severity >= EventSeverity.Error ||
+ isPhaseTransition ||
+ evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true ||
+ isAppEvent)
+ {
+ _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)");
+ Task.Run(() => UploadEventsAsync());
+ }
+ }
+
+ ///
+ /// Called when new events are available in the spool (FileSystemWatcher detected new files)
+ /// Uses debouncing to batch events before uploading
+ ///
+ private void OnEventsAvailable(object sender, EventArgs e)
+ {
+ _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
+
+ // Reset debounce timer - wait for batch window before uploading
+ // This allows multiple events to accumulate, reducing API calls
+ lock (_timerLock)
+ {
+ _debounceTimer.Change(
+ TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
+ Timeout.InfiniteTimeSpan
+ );
+ }
+ }
+
+ ///
+ /// Called when debounce timer expires - uploads batched events
+ ///
+ private void DebounceTimerCallback(object state)
+ {
+ _logger.Debug("Debounce timer expired, uploading batched events");
+ Task.Run(() => UploadEventsAsync());
+ }
+
+ ///
+ /// Fallback timer callback in case FileSystemWatcher misses events
+ ///
+ private void UploadTimerCallback(object state)
+ {
+ Task.Run(() => UploadEventsAsync());
+ }
+
+ private async Task UploadEventsAsync()
+ {
+ // Prevent concurrent uploads — if one is already in flight, skip this call.
+ // The events will be picked up by the next trigger or debounce timer.
+ if (!_uploadSemaphore.Wait(0))
+ return;
+
+ try
+ {
+ var events = _spool.GetBatch(_configuration.MaxBatchSize);
+
+ if (events.Count == 0)
+ {
+ _logger.Debug("No events to upload");
+ return;
+ }
+
+ var request = new IngestEventsRequest
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Events = events
+ };
+
+ var response = await _apiClient.IngestEventsAsync(request);
+
+ if (response.DeviceKillSignal)
+ {
+ _logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ===");
+
+ // Stop all timers — no further uploads
+ _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+
+ // Force self-destruct regardless of current config, respecting KeepLogFile from tenant config
+ _configuration.SelfDestructOnComplete = true;
+ _cleanupService.ExecuteSelfDestruct();
+ Environment.Exit(0);
+ return;
+ }
+
+ if (response.DeviceBlocked)
+ {
+ var unblockMsg = response.UnblockAt.HasValue
+ ? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
+ : string.Empty;
+ _logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
+
+ // Stop upload timers — no further uploads for the duration of this run.
+ // The block is temporary; the next agent run (after reboot / re-enrollment) will check again.
+ _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ return;
+ }
+
+ if (response.Success)
+ {
+ _spool.RemoveEvents(events);
+ _logger.Info($"Uploaded {response.EventsProcessed} events");
+
+ // Reset failure counters on success
+ _consecutiveAuthFailures = 0;
+ _firstAuthFailureTime = null;
+ _consecutiveUploadFailures = 0;
+ }
+ else
+ {
+ _logger.Warning($"Upload failed: {response.Message}");
+ }
+ }
+ catch (BackendAuthException ex)
+ {
+ _logger.Error($"Upload authentication failed: {ex.Message}");
+ HandleAuthFailure();
+ }
+ catch (Exception ex)
+ {
+ _consecutiveUploadFailures++;
+ _logger.Error("Error uploading events", ex);
+
+ // After N consecutive non-auth failures, signal the emergency channel once.
+ // The EmergencyReporter deduplicates further calls for the same error category.
+ if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
+ {
+ _ = _emergencyReporter.TrySendAsync(
+ AgentErrorType.IngestFailed,
+ ex.Message,
+ httpStatusCode: null,
+ sequenceNumber: _eventSequence);
+ }
+ }
+ finally
+ {
+ _uploadSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Handles an authentication failure by incrementing the counter and shutting down
+ /// the agent if the configured threshold is reached.
+ ///
+ private void HandleAuthFailure()
+ {
+ _consecutiveAuthFailures++;
+
+ if (_firstAuthFailureTime == null)
+ _firstAuthFailureTime = DateTime.UtcNow;
+
+ _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
+ (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
+ $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
+
+ // Check max attempts (0 = disabled)
+ if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
+ {
+ _logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
+ "The device is not authorized to send data to Autopilot Monitor. " +
+ "Check client certificate and Autopilot device validation in your tenant configuration. ===");
+ Environment.Exit(1);
+ }
+
+ // Check timeout (0 = disabled)
+ if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
+ {
+ var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
+ if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
+ {
+ _logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
+ $"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
+ "The device is not authorized to send data to Autopilot Monitor. " +
+ "Check client certificate and Autopilot device validation in your tenant configuration. ===");
+ Environment.Exit(1);
+ }
+ }
+ }
+
+ ///
+ /// Deletes the persisted session ID and sequence files.
+ /// Should be called when enrollment is complete/failed.
+ ///
+ private void DeleteSessionId()
+ {
+ _logger.Info("Deleting persisted session ID and sequence...");
+ try
+ {
+ _sessionPersistence.DeleteSession(); // Deletes session.id AND session.seq
+ _logger.Info("Session ID and sequence deleted successfully");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to delete session ID: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Uploads diagnostics package with timeline events (start + result).
+ /// Emits events, uploads them, then performs the actual diagnostics upload.
+ ///
+ private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
+ {
+ var mode = _configuration.DiagnosticsUploadMode ?? "Off";
+ if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
+ return;
+ if (!_configuration.DiagnosticsUploadEnabled)
+ return;
+ if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
+ return;
+
+ // Emit "collecting" event
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "diagnostics_collecting",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
+ Data = new Dictionary
+ {
+ { "uploadMode", mode }
+ }
+ });
+ await UploadEventsAsync();
+
+ // Perform the actual ZIP + upload
+ var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
+ var success = uploadResult?.Success == true;
+
+ // Emit result event (includes blobName so backend can store it on the session)
+ var resultData = new Dictionary
+ {
+ { "uploadMode", mode }
+ };
+ if (uploadResult?.BlobName != null)
+ resultData["blobName"] = uploadResult.BlobName;
+ if (uploadResult?.SasUrlPrefix != null)
+ resultData["sasUrl"] = uploadResult.SasUrlPrefix;
+ if (uploadResult?.ErrorCode != null)
+ resultData["errorCode"] = uploadResult.ErrorCode;
+
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
+ Severity = success ? EventSeverity.Info : EventSeverity.Warning,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = success
+ ? $"Diagnostics package uploaded: {uploadResult.BlobName}"
+ : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
+ Data = resultData
+ });
+ await UploadEventsAsync();
+ }
+
+ ///
+ /// Handles enrollment completion and triggers self-destruct sequence
+ ///
+ private async Task HandleEnrollmentComplete(bool enrollmentSucceeded)
+ {
+ try
+ {
+ _logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence =====");
+
+ // Step 1: Stop all event collectors
+ _logger.Info("Stopping event collectors...");
+ StopEventCollectors();
+ _spool.StopWatching();
+
+ // Step 1.5: Run shutdown analyzers to capture end-state (delta from startup)
+ RunShutdownAnalyzers();
+
+ // Step 2: Upload all remaining events (includes analyzer findings)
+ _logger.Info("Uploading final events...");
+ await UploadEventsAsync();
+
+ // Give a moment for final upload to complete
+ await Task.Delay(2000);
+
+ // Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot)
+ await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
+
+ // Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only
+ if (_configuration.SelfDestructOnComplete)
+ {
+ _cleanupService.ExecuteSelfDestruct();
+ }
+ else if (_configuration.RebootOnComplete)
+ {
+ _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
+
+ // Emit reboot event into the timeline before shutdown
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "reboot_triggered",
+ Severity = EventSeverity.Info,
+ Source = "MonitoringService",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
+ Data = new Dictionary
+ {
+ { "rebootDelaySeconds", _configuration.RebootDelaySeconds }
+ }
+ });
+
+ // Final upload to ensure the reboot event is sent
+ await UploadEventsAsync();
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "shutdown.exe",
+ Arguments = $"/r /t {_configuration.RebootDelaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ Process.Start(psi);
+ }
+
+ _logger.Info("Self-destruct sequence initiated. Agent will now exit.");
+
+ // Exit the application
+ Environment.Exit(0);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error during self-destruct sequence", ex);
+ Environment.Exit(1);
+ }
+ }
+
+ ///
+ /// Graceful shutdown sequence after WhiteGlove (Pre-Provisioning) completes.
+ /// Mirrors HandleEnrollmentComplete but does NOT delete the session ID or write the
+ /// enrollment-complete marker — the session must survive for Part 2 (user enrollment).
+ /// Uploads a "preprov" diagnostics package if diagnostics are configured.
+ ///
+ private async Task HandleWhiteGloveComplete()
+ {
+ try
+ {
+ _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
+
+ // Step 1: Drain the entire spool — whiteglove_complete may be beyond the first
+ // MaxBatchSize (100) events, so loop until empty.
+ _logger.Info("Uploading final events (draining spool)...");
+ int maxIterations = 20; // safety cap against persistent upload failures
+ while (_spool.GetCount() > 0 && maxIterations-- > 0)
+ {
+ await UploadEventsAsync();
+ }
+
+ // Step 2: Small delay to ensure final network transmission completes
+ await Task.Delay(2000);
+
+ // Step 3: Upload pre-provisioning diagnostics package if configured.
+ // Pass enrollmentSucceeded=true (pre-prov succeeded) and suffix "preprov"
+ // so the blob is named AgentDiagnostics-{sessionId}-{ts}-preprov.zip,
+ // distinguishable from the later full-enrollment package.
+ await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
+
+ // Step 4: Persist final sequence for Boot 2 continuation
+ _sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
+ _logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
+
+ // Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
+ // can signal the backend to transition Pending → InProgress.
+ _sessionPersistence.SaveWhiteGloveComplete();
+ _logger.Info("WhiteGlove marker persisted for Part 2 detection");
+
+ // Step 6: Exit — session.id + session.seq + whiteglove.complete survive so
+ // the agent resumes on next boot.
+ // No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct.
+ // The Windows Autopilot shutdown will power off the device;
+ // the Scheduled Task restarts the agent on the user's first boot.
+ _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
+ Environment.Exit(0);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Error during WhiteGlove shutdown sequence", ex);
+ Environment.Exit(0); // Still exit — don't leave a zombie process
+ }
+ }
+
+ public void Dispose()
+ {
+ _uploadTimer?.Dispose();
+ _debounceTimer?.Dispose();
+ _idleCheckTimer?.Dispose();
+ _apiClient?.Dispose();
+ _spool?.Dispose();
+ _espAndHelloTracker?.Dispose();
+ _logReplay?.Dispose();
+ _performanceCollector?.Dispose();
+ _agentSelfMetricsCollector?.Dispose();
+ _enrollmentTracker?.Dispose();
+ _gatherRuleExecutor?.Dispose();
+ _remoteConfigService?.Dispose();
+ _completionEvent.Dispose();
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
index 7f02a0bf..383cf11d 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
@@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
///
/// Main monitoring service that collects and uploads telemetry
///
- public class MonitoringService : IDisposable
+ public partial class MonitoringService : IDisposable
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
@@ -339,1158 +339,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
///
/// Starts optional collectors based on remote configuration
///
- private void StartOptionalCollectors()
- {
- var config = _remoteConfigService?.CurrentConfig;
- var collectors = config?.Collectors;
-
- _logger.Info("Starting collectors based on remote config");
-
- try
- {
- // PerformanceCollector is always on (feeds UI chart)
- var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
- _performanceCollector = new PerformanceCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _logger,
- perfInterval
- );
- _performanceCollector.Start();
-
- // AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint
- if (collectors?.EnableAgentSelfMetrics != false)
- {
- var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
- _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _apiClient.NetworkMetrics,
- _logger,
- _agentVersion,
- selfMetricsInterval
- );
- _agentSelfMetricsCollector.Start();
- }
-
- // Collector idle timeout — stop periodic collectors when enrollment goes quiet
- _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
- _lastRealEventTime = DateTime.UtcNow;
- _collectorsIdleStopped = false;
- if (_collectorIdleTimeoutMinutes > 0)
- {
- _idleCheckTimer = new Timer(
- IdleCheckCallback,
- null,
- TimeSpan.FromSeconds(60),
- TimeSpan.FromSeconds(60)
- );
- _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
- }
- else
- {
- _logger.Info("Collector idle timeout disabled (0)");
- }
-
- // EnrollmentTracker: smart enrollment tracking with IME log parsing
- // (replaces DownloadProgressCollector + EspUiStateCollector)
- // Skip when log replay is active - LogReplayService starts its own EnrollmentTracker
- if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
- {
- var imeLogPatterns = config?.ImeLogPatterns;
- var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
- ? null
- : Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
-
- _enrollmentTracker = new EnrollmentTracker(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _logger,
- imeLogPatterns,
- _configuration.ImeLogPathOverride,
- imeMatchLogPath: imeMatchLogPath,
- espAndHelloTracker: _espAndHelloTracker,
- isBootstrapMode: _configuration.UseBootstrapTokenAuth
- );
- _enrollmentTracker.Start();
- }
-
- // Desktop arrival detector — detects real user desktop for no-ESP completion
- _desktopArrivalDetector = new DesktopArrivalDetector(_logger);
- _desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
- _desktopArrivalDetector.Start();
-
- // Agent max lifetime safety net — prevents zombie agents
- var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
- if (maxLifetimeMinutes > 0)
- {
- _maxLifetimeTimer = new Timer(
- MaxLifetimeTimerCallback,
- null,
- TimeSpan.FromMinutes(maxLifetimeMinutes),
- Timeout.InfiniteTimeSpan);
- _logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
- }
- else
- {
- _logger.Info("Agent max lifetime timer disabled (0)");
- }
- }
- catch (Exception ex)
- {
- _logger.Error("Error starting optional collectors", ex);
- }
- }
-
- ///
- /// Stops all optional collectors
- ///
- private void StopOptionalCollectors()
- {
- _idleCheckTimer?.Dispose();
- _idleCheckTimer = null;
-
- _maxLifetimeTimer?.Dispose();
- _maxLifetimeTimer = null;
-
- if (_desktopArrivalDetector != null)
- {
- _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
- _desktopArrivalDetector.Dispose();
- _desktopArrivalDetector = null;
- }
-
- _performanceCollector?.Stop();
- _performanceCollector?.Dispose();
- _performanceCollector = null;
-
- _agentSelfMetricsCollector?.Stop();
- _agentSelfMetricsCollector?.Dispose();
- _agentSelfMetricsCollector = null;
-
- _enrollmentTracker?.Stop();
- _enrollmentTracker?.Dispose();
- _enrollmentTracker = null;
- }
-
- ///
- /// Fires when the agent max lifetime expires. Emits enrollment_failed with failureType "agent_timeout".
- /// The normal shutdown path (EmitEvent → enrollment_failed) handles everything from here.
- ///
- private void MaxLifetimeTimerCallback(object state)
- {
- if (_enrollmentTerminalEventSeen)
- {
- _logger.Debug("Max lifetime timer fired but terminal event already seen — ignoring");
- return;
- }
-
- var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
- _logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "enrollment_failed",
- Severity = EventSeverity.Error,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
- Data = new Dictionary
- {
- { "failureType", "agent_timeout" },
- { "failureSource", "max_lifetime_timer" },
- { "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
- }
- });
- }
-
- ///
- /// Fires when DesktopArrivalDetector detects explorer.exe under a real user.
- /// Emits desktop_arrived event for the timeline and notifies EnrollmentTracker.
- ///
- private void OnDesktopArrived(object sender, EventArgs e)
- {
- if (_enrollmentTerminalEventSeen)
- {
- _logger.Debug("Desktop arrived but terminal event already seen — ignoring");
- return;
- }
-
- // Emit informational event for the timeline (shows when user desktop appeared)
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "desktop_arrived",
- Severity = EventSeverity.Info,
- Source = "DesktopArrivalDetector",
- Phase = EnrollmentPhase.Unknown,
- Message = "User desktop detected (explorer.exe under real user)"
- });
-
- // Notify EnrollmentTracker — it decides whether this triggers completion
- _enrollmentTracker?.NotifyDesktopArrived();
- }
-
- ///
- /// Returns true for events generated by periodic timers (not real enrollment activity)
- ///
- private static bool IsPeriodicEvent(string eventType)
- {
- return eventType == "performance_snapshot" ||
- eventType == "agent_metrics_snapshot" ||
- eventType == "performance_collector_stopped" ||
- eventType == "agent_metrics_collector_stopped";
- }
-
- ///
- /// Periodic check (every 60s) whether enrollment has gone idle.
- /// When no real event has arrived within the configured timeout window,
- /// periodic collectors are stopped to prevent session bloat.
- ///
- private void IdleCheckCallback(object state)
- {
- if (_collectorsIdleStopped)
- return;
-
- var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
- if (idleMinutes < _collectorIdleTimeoutMinutes)
- return;
-
- _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
-
- // Stop PerformanceCollector
- if (_performanceCollector != null)
- {
- _performanceCollector.Stop();
- _performanceCollector.Dispose();
- _performanceCollector = null;
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "performance_collector_stopped",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
- Data = new Dictionary
- {
- { "reason", "idle_timeout" },
- { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
- { "idleMinutes", Math.Round(idleMinutes, 1) }
- }
- });
- }
-
- // Stop AgentSelfMetricsCollector
- if (_agentSelfMetricsCollector != null)
- {
- _agentSelfMetricsCollector.Stop();
- _agentSelfMetricsCollector.Dispose();
- _agentSelfMetricsCollector = null;
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "agent_metrics_collector_stopped",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Unknown,
- Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
- Data = new Dictionary
- {
- { "reason", "idle_timeout" },
- { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
- { "idleMinutes", Math.Round(idleMinutes, 1) }
- }
- });
- }
-
- _collectorsIdleStopped = true;
-
- // Stop the idle check timer — it will be restarted if collectors resume
- _idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- }
-
- ///
- /// Restarts periodic collectors after they were stopped due to idle timeout.
- /// Called when new real enrollment activity is detected.
- ///
- private void RestartPeriodicCollectors()
- {
- var config = _remoteConfigService?.CurrentConfig;
- var collectors = config?.Collectors;
-
- _logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
-
- try
- {
- var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
- _performanceCollector = new PerformanceCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _logger,
- perfInterval
- );
- _performanceCollector.Start();
-
- if (collectors?.EnableAgentSelfMetrics != false)
- {
- var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
- _agentSelfMetricsCollector = new AgentSelfMetricsCollector(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _apiClient.NetworkMetrics,
- _logger,
- _agentVersion,
- selfMetricsInterval
- );
- _agentSelfMetricsCollector.Start();
- }
-
- // Reset idle tracking and restart the check timer
- _lastRealEventTime = DateTime.UtcNow;
- _collectorsIdleStopped = false;
- if (_collectorIdleTimeoutMinutes > 0)
- {
- _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
- }
- }
- catch (Exception ex)
- {
- _logger.Error("Error restarting periodic collectors", ex);
- }
- }
-
- ///
- /// Starts the gather rule executor with rules from remote config
- ///
- private void StartGatherRuleExecutor()
- {
- var config = _remoteConfigService?.CurrentConfig;
- if (config?.GatherRules == null || config.GatherRules.Count == 0)
- {
- _logger.Info("No gather rules to execute");
- return;
- }
-
- try
- {
- _gatherRuleExecutor = new GatherRuleExecutor(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _logger,
- _configuration.ImeLogPathOverride
- );
- _gatherRuleExecutor.UpdateRules(config.GatherRules);
- }
- catch (Exception ex)
- {
- _logger.Error("Error starting gather rule executor", ex);
- }
- }
-
- ///
- /// Initializes agent analyzers from remote config.
- /// Called once during Start(), after all collectors are started.
- ///
- private void InitializeAnalyzers()
- {
- _analyzers.Clear();
-
- var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
-
- if (analyzerConfig.EnableLocalAdminAnalyzer)
- {
- _analyzers.Add(new LocalAdminAnalyzer(
- _configuration.SessionId,
- _configuration.TenantId,
- EmitEvent,
- _logger,
- analyzerConfig.LocalAdminAllowedAccounts
- ));
- _logger.Info("LocalAdminAnalyzer registered");
- }
- else
- {
- _logger.Info("LocalAdminAnalyzer disabled by remote config");
- }
-
- _logger.Info($"Analyzers initialized: {_analyzers.Count} active");
- }
-
- ///
- /// Runs all registered analyzers at agent startup — asynchronously on the ThreadPool
- /// so they do not block or delay the agent start sequence.
- /// Emitted events will be picked up by the spool watcher and uploaded normally.
- ///
- private void RunStartupAnalyzers()
- {
- if (_analyzers.Count == 0)
- return;
-
- // Capture the list snapshot — _analyzers is not modified after Start()
- var analyzers = new List(_analyzers);
-
- _logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
-
- Task.Run(() =>
- {
- foreach (var analyzer in analyzers)
- {
- try
- {
- analyzer.AnalyzeAtStartup();
- }
- catch (Exception ex)
- {
- _logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
- }
- }
- });
- }
-
- ///
- /// Runs all registered analyzers at shutdown / enrollment completion.
- /// Emits end-state findings for delta detection against the startup results.
- ///
- private void RunShutdownAnalyzers()
- {
- if (_analyzers.Count == 0)
- return;
-
- _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s)");
-
- foreach (var analyzer in _analyzers)
- {
- try
- {
- analyzer.AnalyzeAtShutdown();
- }
- catch (Exception ex)
- {
- _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
- }
- }
- }
-
- ///
- /// Registers the session with the backend. Retries with exponential backoff
- /// to handle network not being ready during OOBE boot (WhiteGlove Part 2).
- ///
- private async Task RegisterSessionAsync()
- {
- const int maxAttempts = 5;
-
- var registration = new SessionRegistration
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- SerialNumber = DeviceInfoProvider.GetSerialNumber(),
- Manufacturer = DeviceInfoProvider.GetManufacturer(),
- Model = DeviceInfoProvider.GetModel(),
- DeviceName = Environment.MachineName,
- OsBuild = Environment.OSVersion.Version.ToString(),
- OsEdition = DeviceInfoProvider.GetOsEdition(),
- OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
- StartedAt = DateTime.UtcNow,
- AgentVersion = _agentVersion,
- EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(),
- IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(),
- IsUserDriven = true
- };
-
- for (int attempt = 1; attempt <= maxAttempts; attempt++)
- {
- try
- {
- _logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})");
-
- var response = await _apiClient.RegisterSessionAsync(registration);
-
- if (response.Success)
- {
- _logger.Info($"Session registered successfully: {response.SessionId}");
- return;
- }
-
- _logger.Warning($"Session registration failed: {response.Message}");
- }
- catch (BackendAuthException ex)
- {
- _logger.Error($"Session registration authentication failed: {ex.Message}");
- HandleAuthFailure();
- return; // Auth failure is not retryable
- }
- catch (Exception ex)
- {
- _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex);
-
- if (attempt == maxAttempts)
- {
- // Report to emergency channel on final failure
- _ = _emergencyReporter.TrySendAsync(
- AgentErrorType.RegisterSessionFailed,
- ex.Message);
- return;
- }
- }
-
- // Exponential backoff: 2s, 4s, 8s, 16s
- var delaySeconds = (int)Math.Pow(2, attempt);
- _logger.Info($"Retrying session registration in {delaySeconds}s");
- await Task.Delay(delaySeconds * 1000);
- }
- }
-
- ///
- /// Stops the monitoring service
- ///
- public void Stop()
- {
- _logger.Info("Stopping monitoring service");
-
- // Stop FileSystemWatcher
- _spool.StopWatching();
-
- // Stop event collectors
- StopEventCollectors();
-
- // Run shutdown analyzers before final event (captures end-state for delta detection)
- RunShutdownAnalyzers();
-
- // Emit final event
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- EventType = "agent_stopped",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = _lastPhase ?? EnrollmentPhase.Unknown,
- Message = "Autopilot Monitor Agent stopped"
- });
-
- // Final upload attempt
- UploadEventsAsync().Wait(TimeSpan.FromSeconds(10));
-
- _logger.Info("Monitoring service stopped");
-
- // Unblock WaitForCompletion() in background/SYSTEM mode
- _completionEvent.Set();
- }
-
- ///
- /// Blocks the calling thread until the service stops (used when running as Scheduled Task
- /// under SYSTEM where there is no interactive Console.ReadLine() to keep the process alive).
- ///
- public void WaitForCompletion()
- {
- _completionEvent.Wait();
- }
-
- ///
- /// Triggers cleanup without running enrollment monitoring.
- /// Used when enrollment complete marker is detected on startup (cleanup retry).
- ///
- public void TriggerCleanup()
- {
- _logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring");
-
- if (_configuration.SelfDestructOnComplete)
- {
- _cleanupService.ExecuteSelfDestruct();
- }
- else
- {
- _logger.Info("SelfDestructOnComplete is disabled - nothing to clean up");
- }
- }
-
- ///
- /// Stops all event collection components
- ///
- private void StopEventCollectors()
- {
- _logger.Info("Stopping event collectors");
-
- try
- {
- _espAndHelloTracker?.Stop();
- _espAndHelloTracker?.Dispose();
-
- _logReplay?.Stop();
- _logReplay?.Dispose();
-
- // Stop optional collectors
- StopOptionalCollectors();
-
- // Stop gather rule executor
- _gatherRuleExecutor?.Dispose();
- _gatherRuleExecutor = null;
-
- _logger.Info("Event collectors stopped");
- }
- catch (Exception ex)
- {
- _logger.Error("Error stopping event collectors", ex);
- }
- }
-
- ///
- /// Emits an event (adds to spool)
- ///
- public void EmitEvent(EnrollmentEvent evt)
- {
- evt.Sequence = Interlocked.Increment(ref _eventSequence);
- _spool.Add(evt);
- _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}");
-
- // Track real enrollment activity for idle timeout
- if (!string.IsNullOrEmpty(evt.EventType) && !IsPeriodicEvent(evt.EventType))
- {
- _lastRealEventTime = DateTime.UtcNow;
-
- // Restart periodic collectors if they were stopped due to idle
- if (_collectorsIdleStopped)
- {
- RestartPeriodicCollectors();
- }
- }
-
- // Persist sequence periodically (every 50 events) + always on critical events
- if (evt.Sequence % 50 == 0 ||
- evt.EventType == "whiteglove_complete" ||
- evt.EventType == "enrollment_complete" ||
- evt.EventType == "enrollment_failed")
- {
- _sessionPersistence.SaveSequence(evt.Sequence);
- }
-
- // Cancel max lifetime timer on terminal events
- if (evt.EventType == "whiteglove_complete" ||
- evt.EventType == "enrollment_complete" ||
- evt.EventType == "enrollment_failed")
- {
- _enrollmentTerminalEventSeen = true;
- _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- }
-
- // Check if this is a phase transition
- bool isPhaseTransition = false;
- if (evt.Phase != _lastPhase)
- {
- _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}");
- _lastPhase = evt.Phase;
- isPhaseTransition = true;
-
- // Notify gather rule executor of phase change
- try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { }
- }
-
- // Notify gather rule executor of event type (for on_event triggers)
- if (!string.IsNullOrEmpty(evt.EventType))
- {
- try { _gatherRuleExecutor?.OnEvent(evt.EventType); } catch { }
- }
-
- // Check for WhiteGlove completion — agent exits gracefully, session stays open
- if (evt.EventType == "whiteglove_complete")
- {
- _logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence.");
-
- // Stop performance collection immediately (no value while device is off)
- _performanceCollector?.Stop();
- _performanceCollector?.Dispose();
- _performanceCollector = null;
-
- // Stop all event collectors and the spool watcher — no new events after this point
- StopEventCollectors();
- _spool.StopWatching();
-
- // Stop upload timers — we drive uploads manually from here
- _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
-
- Task.Run(() => HandleWhiteGloveComplete());
-
- return; // Don't continue with normal event processing
- }
-
- // Check for enrollment completion events
- if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed")
- {
- var enrollmentSucceeded = evt.EventType == "enrollment_complete";
- _logger.Info($"Enrollment completion detected: {evt.EventType}");
-
- // Delete session ID so a new session will be created on next enrollment
- DeleteSessionId();
-
- // Stop ALL collectors to minimize system impact
- _logger.Info("Stopping all collectors after enrollment completion...");
- StopEventCollectors();
-
- // Stop upload timers - no more periodic uploads needed
- _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
-
- // Final upload to flush any remaining events
- Task.Run(async () =>
- {
- try
- {
- await UploadEventsAsync();
- _logger.Info("Final event upload after enrollment completion done");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}");
- }
- });
-
- // Trigger self-destruct or reboot if configured
- if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete)
- {
- Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded));
- return; // Don't continue with normal event processing
- }
-
- // No self-destruct/reboot — still upload diagnostics if configured
- Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded));
- }
-
- // Immediate upload for:
- // 1. Critical events (errors) - for troubleshooting
- // 2. Phase transitions (start/end) - for real-time phase tracking in UI
- // 3. Events with "phase" in EventType - explicit phase-related events
- // 4. App download/install events - for real-time download progress UI updates
- var isAppEvent = evt.EventType?.StartsWith("app_", StringComparison.OrdinalIgnoreCase) == true;
-
- if (evt.Severity >= EventSeverity.Error ||
- isPhaseTransition ||
- evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true ||
- isAppEvent)
- {
- _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)");
- Task.Run(() => UploadEventsAsync());
- }
- }
-
- ///
- /// Called when new events are available in the spool (FileSystemWatcher detected new files)
- /// Uses debouncing to batch events before uploading
- ///
- private void OnEventsAvailable(object sender, EventArgs e)
- {
- _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
-
- // Reset debounce timer - wait for batch window before uploading
- // This allows multiple events to accumulate, reducing API calls
- lock (_timerLock)
- {
- _debounceTimer.Change(
- TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
- Timeout.InfiniteTimeSpan
- );
- }
- }
-
- ///
- /// Called when debounce timer expires - uploads batched events
- ///
- private void DebounceTimerCallback(object state)
- {
- _logger.Debug("Debounce timer expired, uploading batched events");
- Task.Run(() => UploadEventsAsync());
- }
-
- ///
- /// Fallback timer callback in case FileSystemWatcher misses events
- ///
- private void UploadTimerCallback(object state)
- {
- Task.Run(() => UploadEventsAsync());
- }
-
- private async Task UploadEventsAsync()
- {
- // Prevent concurrent uploads — if one is already in flight, skip this call.
- // The events will be picked up by the next trigger or debounce timer.
- if (!_uploadSemaphore.Wait(0))
- return;
-
- try
- {
- var events = _spool.GetBatch(_configuration.MaxBatchSize);
-
- if (events.Count == 0)
- {
- _logger.Debug("No events to upload");
- return;
- }
-
- var request = new IngestEventsRequest
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Events = events
- };
-
- var response = await _apiClient.IngestEventsAsync(request);
-
- if (response.DeviceKillSignal)
- {
- _logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ===");
-
- // Stop all timers — no further uploads
- _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
-
- // Force self-destruct regardless of current config, respecting KeepLogFile from tenant config
- _configuration.SelfDestructOnComplete = true;
- _cleanupService.ExecuteSelfDestruct();
- Environment.Exit(0);
- return;
- }
-
- if (response.DeviceBlocked)
- {
- var unblockMsg = response.UnblockAt.HasValue
- ? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
- : string.Empty;
- _logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
-
- // Stop upload timers — no further uploads for the duration of this run.
- // The block is temporary; the next agent run (after reboot / re-enrollment) will check again.
- _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- return;
- }
-
- if (response.Success)
- {
- _spool.RemoveEvents(events);
- _logger.Info($"Uploaded {response.EventsProcessed} events");
-
- // Reset failure counters on success
- _consecutiveAuthFailures = 0;
- _firstAuthFailureTime = null;
- _consecutiveUploadFailures = 0;
- }
- else
- {
- _logger.Warning($"Upload failed: {response.Message}");
- }
- }
- catch (BackendAuthException ex)
- {
- _logger.Error($"Upload authentication failed: {ex.Message}");
- HandleAuthFailure();
- }
- catch (Exception ex)
- {
- _consecutiveUploadFailures++;
- _logger.Error("Error uploading events", ex);
-
- // After N consecutive non-auth failures, signal the emergency channel once.
- // The EmergencyReporter deduplicates further calls for the same error category.
- if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
- {
- _ = _emergencyReporter.TrySendAsync(
- AgentErrorType.IngestFailed,
- ex.Message,
- httpStatusCode: null,
- sequenceNumber: _eventSequence);
- }
- }
- finally
- {
- _uploadSemaphore.Release();
- }
- }
-
- ///
- /// Handles an authentication failure by incrementing the counter and shutting down
- /// the agent if the configured threshold is reached.
- ///
- private void HandleAuthFailure()
- {
- _consecutiveAuthFailures++;
-
- if (_firstAuthFailureTime == null)
- _firstAuthFailureTime = DateTime.UtcNow;
-
- _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
- (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
- $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
-
- // Check max attempts (0 = disabled)
- if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
- {
- _logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
- "The device is not authorized to send data to Autopilot Monitor. " +
- "Check client certificate and Autopilot device validation in your tenant configuration. ===");
- Environment.Exit(1);
- }
-
- // Check timeout (0 = disabled)
- if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
- {
- var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
- if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
- {
- _logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
- $"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
- "The device is not authorized to send data to Autopilot Monitor. " +
- "Check client certificate and Autopilot device validation in your tenant configuration. ===");
- Environment.Exit(1);
- }
- }
- }
-
- ///
- /// Deletes the persisted session ID and sequence files.
- /// Should be called when enrollment is complete/failed.
- ///
- private void DeleteSessionId()
- {
- _logger.Info("Deleting persisted session ID and sequence...");
- try
- {
- _sessionPersistence.DeleteSession(); // Deletes session.id AND session.seq
- _logger.Info("Session ID and sequence deleted successfully");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to delete session ID: {ex.Message}");
- }
- }
-
- ///
- /// Uploads diagnostics package with timeline events (start + result).
- /// Emits events, uploads them, then performs the actual diagnostics upload.
- ///
- private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
- {
- var mode = _configuration.DiagnosticsUploadMode ?? "Off";
- if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
- return;
- if (!_configuration.DiagnosticsUploadEnabled)
- return;
- if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
- return;
-
- // Emit "collecting" event
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "diagnostics_collecting",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
- Data = new Dictionary
- {
- { "uploadMode", mode }
- }
- });
- await UploadEventsAsync();
-
- // Perform the actual ZIP + upload
- var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
- var success = uploadResult?.Success == true;
-
- // Emit result event (includes blobName so backend can store it on the session)
- var resultData = new Dictionary
- {
- { "uploadMode", mode }
- };
- if (uploadResult?.BlobName != null)
- resultData["blobName"] = uploadResult.BlobName;
- if (uploadResult?.SasUrlPrefix != null)
- resultData["sasUrl"] = uploadResult.SasUrlPrefix;
- if (uploadResult?.ErrorCode != null)
- resultData["errorCode"] = uploadResult.ErrorCode;
-
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
- Severity = success ? EventSeverity.Info : EventSeverity.Warning,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = success
- ? $"Diagnostics package uploaded: {uploadResult.BlobName}"
- : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
- Data = resultData
- });
- await UploadEventsAsync();
- }
-
- ///
- /// Handles enrollment completion and triggers self-destruct sequence
- ///
- private async Task HandleEnrollmentComplete(bool enrollmentSucceeded)
- {
- try
- {
- _logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence =====");
-
- // Step 1: Stop all event collectors
- _logger.Info("Stopping event collectors...");
- StopEventCollectors();
- _spool.StopWatching();
-
- // Step 1.5: Run shutdown analyzers to capture end-state (delta from startup)
- RunShutdownAnalyzers();
-
- // Step 2: Upload all remaining events (includes analyzer findings)
- _logger.Info("Uploading final events...");
- await UploadEventsAsync();
-
- // Give a moment for final upload to complete
- await Task.Delay(2000);
-
- // Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot)
- await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
-
- // Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only
- if (_configuration.SelfDestructOnComplete)
- {
- _cleanupService.ExecuteSelfDestruct();
- }
- else if (_configuration.RebootOnComplete)
- {
- _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
-
- // Emit reboot event into the timeline before shutdown
- EmitEvent(new EnrollmentEvent
- {
- SessionId = _configuration.SessionId,
- TenantId = _configuration.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "reboot_triggered",
- Severity = EventSeverity.Info,
- Source = "MonitoringService",
- Phase = EnrollmentPhase.Complete,
- Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
- Data = new Dictionary
- {
- { "rebootDelaySeconds", _configuration.RebootDelaySeconds }
- }
- });
-
- // Final upload to ensure the reboot event is sent
- await UploadEventsAsync();
-
- var psi = new ProcessStartInfo
- {
- FileName = "shutdown.exe",
- Arguments = $"/r /t {_configuration.RebootDelaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
- UseShellExecute = false,
- CreateNoWindow = true
- };
- Process.Start(psi);
- }
-
- _logger.Info("Self-destruct sequence initiated. Agent will now exit.");
-
- // Exit the application
- Environment.Exit(0);
- }
- catch (Exception ex)
- {
- _logger.Error("Error during self-destruct sequence", ex);
- Environment.Exit(1);
- }
- }
-
- ///
- /// Graceful shutdown sequence after WhiteGlove (Pre-Provisioning) completes.
- /// Mirrors HandleEnrollmentComplete but does NOT delete the session ID or write the
- /// enrollment-complete marker — the session must survive for Part 2 (user enrollment).
- /// Uploads a "preprov" diagnostics package if diagnostics are configured.
- ///
- private async Task HandleWhiteGloveComplete()
- {
- try
- {
- _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
-
- // Step 1: Drain the entire spool — whiteglove_complete may be beyond the first
- // MaxBatchSize (100) events, so loop until empty.
- _logger.Info("Uploading final events (draining spool)...");
- int maxIterations = 20; // safety cap against persistent upload failures
- while (_spool.GetCount() > 0 && maxIterations-- > 0)
- {
- await UploadEventsAsync();
- }
-
- // Step 2: Small delay to ensure final network transmission completes
- await Task.Delay(2000);
-
- // Step 3: Upload pre-provisioning diagnostics package if configured.
- // Pass enrollmentSucceeded=true (pre-prov succeeded) and suffix "preprov"
- // so the blob is named AgentDiagnostics-{sessionId}-{ts}-preprov.zip,
- // distinguishable from the later full-enrollment package.
- await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
-
- // Step 4: Persist final sequence for Boot 2 continuation
- _sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
- _logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
-
- // Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
- // can signal the backend to transition Pending → InProgress.
- _sessionPersistence.SaveWhiteGloveComplete();
- _logger.Info("WhiteGlove marker persisted for Part 2 detection");
-
- // Step 6: Exit — session.id + session.seq + whiteglove.complete survive so
- // the agent resumes on next boot.
- // No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct.
- // The Windows Autopilot shutdown will power off the device;
- // the Scheduled Task restarts the agent on the user's first boot.
- _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
- Environment.Exit(0);
- }
- catch (Exception ex)
- {
- _logger.Error("Error during WhiteGlove shutdown sequence", ex);
- Environment.Exit(0); // Still exit — don't leave a zombie process
- }
- }
-
- public void Dispose()
- {
- _uploadTimer?.Dispose();
- _debounceTimer?.Dispose();
- _idleCheckTimer?.Dispose();
- _apiClient?.Dispose();
- _spool?.Dispose();
- _espAndHelloTracker?.Dispose();
- _logReplay?.Dispose();
- _performanceCollector?.Dispose();
- _agentSelfMetricsCollector?.Dispose();
- _enrollmentTracker?.Dispose();
- _gatherRuleExecutor?.Dispose();
- _remoteConfigService?.Dispose();
- _completionEvent.Dispose();
- }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs
new file mode 100644
index 00000000..5fe59395
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs
@@ -0,0 +1,589 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
+{
+ ///
+ /// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling,
+ /// state persistence, and multi-signal completion logic.
+ ///
+ public partial class EnrollmentTracker
+ {
+ private void OnHelloCompleted(object sender, EventArgs e)
+ {
+ _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker");
+ _stateData.HelloResolvedUtc = DateTime.UtcNow;
+ RecordSignal("hello_resolved");
+
+ // Cancel safety timer — Hello resolved normally
+ _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+
+ if (_isWaitingForHello)
+ {
+ _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now");
+ _isWaitingForHello = false;
+ TryEmitEnrollmentComplete("ime_hello");
+ }
+ else if (_espFinalExitSeen)
+ {
+ _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion");
+ TryEmitEnrollmentComplete("esp_hello_composite");
+ }
+ else if (_desktopArrived)
+ {
+ _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion");
+ TryEmitEnrollmentComplete("desktop_hello");
+ }
+ else
+ {
+ _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet");
+ }
+ }
+
+ ///
+ /// Called when WhiteGlove (Pre-Provisioning) completes successfully.
+ /// Emits the whiteglove_complete event. The MonitoringService handles
+ /// the actual agent shutdown upon seeing this event type.
+ ///
+ private void OnWhiteGloveCompleted(object sender, EventArgs e)
+ {
+ _logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed");
+
+ // Stop summary timer — no more app tracking needed
+ _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
+ _summaryTimerActive = false;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "whiteglove_complete",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state"
+ });
+
+ // WICHTIG: Kein WriteEnrollmentCompleteMarker()!
+ // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft.
+ // WICHTIG: Kein _imeLogTracker?.DeleteState()!
+ // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
+ }
+
+ ///
+ /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
+ /// Terminal failures emit enrollment_failed immediately.
+ /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
+ ///
+ private void OnEspFailureDetected(object sender, string failureType)
+ {
+ _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
+
+ if (RecoverableEspFailureTypes.Contains(failureType))
+ {
+ // Recoverable failure — start grace period timer
+ _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period");
+ _pendingEspFailureType = failureType;
+
+ // Cancel existing timer if any (e.g. second timeout event)
+ _espFailureTimer?.Dispose();
+ _espFailureTimer = new Timer(
+ OnEspFailureGracePeriodExpired,
+ null,
+ TimeSpan.FromSeconds(EspFailureGracePeriodSeconds),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ else
+ {
+ // Terminal failure — emit enrollment_failed immediately
+ _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately");
+ EmitEnrollmentFailed(failureType, "esp_failure");
+ }
+ }
+
+ ///
+ /// Called when the ESP failure grace period expires without recovery.
+ /// Emits enrollment_failed.
+ ///
+ private void OnEspFailureGracePeriodExpired(object state)
+ {
+ var failureType = _pendingEspFailureType ?? "unknown";
+ _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed");
+ _pendingEspFailureType = null;
+
+ EmitEnrollmentFailed(failureType, "esp_failure_grace_expired");
+ }
+
+ ///
+ /// Cancels any pending ESP failure grace period timer (called when recovery is detected).
+ ///
+ private void CancelPendingEspFailure()
+ {
+ if (_espFailureTimer != null)
+ {
+ _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{_pendingEspFailureType}'");
+ _espFailureTimer.Dispose();
+ _espFailureTimer = null;
+ _pendingEspFailureType = null;
+ }
+ }
+
+ ///
+ /// Safety-net callback: fires 7 minutes after waiting_for_hello was set.
+ /// If the normal Hello timeout chain (30s + 300s) failed for any reason,
+ /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer.
+ ///
+ private void OnWaitingForHelloSafetyTimeout(object state)
+ {
+ if (!_isWaitingForHello || _enrollmentCompleteEmitted)
+ return;
+
+ _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion");
+ _isWaitingForHello = false;
+
+ // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes
+ if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
+ {
+ _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout");
+ }
+
+ TryEmitEnrollmentComplete("ime_hello_safety_timeout");
+ }
+
+ ///
+ /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete.
+ ///
+ private void EmitEnrollmentFailed(string failureType, string failureSource)
+ {
+ // Stop timers
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_failed",
+ Severity = EventSeverity.Error,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Autopilot enrollment failed: {failureType}",
+ Data = new Dictionary
+ {
+ { "failureType", failureType },
+ { "failureSource", failureSource },
+ { "signalsSeen", _stateData.SignalsSeen },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
+ }
+ });
+
+ // Write final status dump before cleanup
+ WriteFinalStatus("failed", failureType);
+
+ // Clean up persisted tracker state
+ _imeLogTracker?.DeleteState();
+ _statePersistence.Delete();
+ }
+
+ ///
+ /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
+ /// Triggers transition to FinalizingSetup phase
+ ///
+ private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
+ {
+ _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}");
+
+ // If ESP exiting, check which phase we're in
+ if (reason == "esp_exiting")
+ {
+ _espEverSeen = true;
+
+ if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) || _desktopArrived)
+ {
+ // Final ESP exit: either AccountSetup phase detected OR desktop arrived (backup)
+ var phaseInfo = string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
+ ? "AccountSetup"
+ : $"{_lastEspPhase ?? "unknown"} (desktop arrival backup)";
+ _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer");
+
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ RecordSignal("esp_final_exit");
+
+ // Emit phase change event to FinalizingSetup
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", reason },
+ { "previousPhase", _lastEspPhase ?? "unknown" },
+ { "desktopArrivedBackup", _desktopArrived && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) }
+ }
+ });
+
+ CollectDeviceInfoAtFinalizingSetup(reason);
+
+ // Start Hello wait timer (waits for Hello wizard to start or timeout)
+ _espAndHelloTracker?.StartHelloWaitTimer();
+
+ // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301
+ // during AccountSetup), the composite signal can fire immediately.
+ TryEmitEnrollmentComplete("esp_hello_composite");
+ }
+ else if (_skipUserStatusPage == true)
+ {
+ // Registry definitively says no AccountSetup expected → immediate device-only classification
+ _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)");
+
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ RecordSignal("device_only_esp_registry");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", "device_only_esp_registry" },
+ { "previousPhase", _lastEspPhase ?? "unknown" },
+ { "skipUserStatusPage", true }
+ }
+ });
+
+ CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry");
+ _espAndHelloTracker?.StartHelloWaitTimer();
+ TryEmitEnrollmentComplete("device_only_esp_registry");
+ }
+ else
+ {
+ // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection
+ var fallbackReason = _skipUserStatusPage == null ? "registry keys not found" : "SkipUserStatusPage=false";
+ _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)");
+
+ // Start device-only ESP detection timer
+ _deviceOnlyEspTimer?.Dispose();
+ _deviceOnlyEspTimer = new Timer(
+ OnDeviceOnlyEspTimerExpired,
+ null,
+ TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+ else if (reason == "hello_wizard_started")
+ {
+ // Hello wizard started - transition to FinalizingSetup regardless of previous phase
+ _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "ESP phase: FinalizingSetup (Hello wizard started)",
+ Data = new Dictionary
+ {
+ { "espPhase", "FinalizingSetup" },
+ { "autoDetected", true },
+ { "triggeredBy", reason }
+ }
+ });
+
+ CollectDeviceInfoAtFinalizingSetup(reason);
+ }
+ }
+
+ // ===== State Persistence =====
+
+ private void LoadState()
+ {
+ var loaded = _statePersistence.Load();
+ if (loaded == null)
+ return;
+
+ _espEverSeen = loaded.EspEverSeen;
+ _espFinalExitSeen = loaded.EspFinalExitSeen;
+ _desktopArrived = loaded.DesktopArrived;
+ _lastEspPhase = loaded.LastEspPhase;
+ _isWaitingForHello = loaded.IsWaitingForHello;
+ _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted;
+ _enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
+ _skipUserStatusPage = loaded.SkipUserStatusPage;
+ _skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
+ _stateData = loaded;
+
+ _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
+
+ // Restart Hello wait timer if needed after crash recovery
+ if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
+ {
+ _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery");
+ _espAndHelloTracker.StartHelloWaitTimer();
+ }
+
+ // Restart safety timer if we were waiting for Hello
+ if (_isWaitingForHello && !_enrollmentCompleteEmitted)
+ {
+ _logger.Info("EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery");
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForHelloSafetyTimer = new Timer(
+ OnWaitingForHelloSafetyTimeout,
+ null,
+ TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+ }
+ }
+
+ private void RecordSignal(string signal)
+ {
+ if (!_stateData.SignalsSeen.Contains(signal))
+ _stateData.SignalsSeen.Add(signal);
+ _stateDirty = true;
+ }
+
+ // ===== Unified Completion Logic =====
+
+ ///
+ /// Central guard method for enrollment_complete emission. All completion paths route through here.
+ /// An _enrollmentCompleteEmitted flag prevents double emission.
+ /// Emits a throttled completion_check event on every call for observability.
+ ///
+ private void TryEmitEnrollmentComplete(string source)
+ {
+ if (_enrollmentCompleteEmitted)
+ {
+ _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping");
+ return;
+ }
+
+ if (string.IsNullOrEmpty(source))
+ return;
+
+ // Hello-Check: Hello must be resolved before we can complete
+ bool helloResolved = _espAndHelloTracker == null
+ || _espAndHelloTracker.IsHelloCompleted
+ || !_espAndHelloTracker.IsPolicyConfigured;
+
+ if (!helloResolved)
+ {
+ _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet");
+ EmitCompletionCheck(source, "hello_pending", "hello_not_resolved");
+ return;
+ }
+
+ // Desktop-Arrival Gate: Block desktop-based completion when ESP is still actively running.
+ // Both "desktop_arrival" (direct) and "desktop_hello" (Hello resolved after desktop arrival)
+ // must be gated — otherwise Hello timeout during active AccountSetup triggers premature completion.
+ // WDP v2 has no ESP — skip the gate entirely.
+ if ((source == "desktop_arrival" || source == "desktop_hello") && _enrollmentType != "v2" && _espEverSeen && !_espFinalExitSeen)
+ {
+ _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active");
+ EmitCompletionCheck(source, "blocked", "esp_active");
+ return;
+ }
+
+ _enrollmentCompleteEmitted = true;
+ _stateData.EnrollmentCompleteEmitted = true;
+
+ // Stop timers
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
+
+ var signalTimestamps = new Dictionary();
+ if (_stateData.EspFirstSeenUtc.HasValue)
+ signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
+ if (_stateData.DesktopArrivedUtc.HasValue)
+ signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
+ if (_stateData.EspFinalExitUtc.HasValue)
+ signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
+ if (_stateData.HelloResolvedUtc.HasValue)
+ signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
+ if (_stateData.ImePatternSeenUtc.HasValue)
+ signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "enrollment_complete",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Autopilot enrollment completed successfully (source: {source})",
+ Data = new Dictionary
+ {
+ { "completionSource", source },
+ { "helloOutcome", helloOutcome },
+ { "signalsSeen", _stateData.SignalsSeen },
+ { "signalTimestamps", signalTimestamps },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
+ }
+ });
+
+ // Write final status dump and enrollment complete marker
+ WriteFinalStatus("completed", source);
+ WriteEnrollmentCompleteMarker();
+
+ // Clean up persisted tracker state so next enrollment starts fresh
+ _imeLogTracker?.DeleteState();
+ _statePersistence.Delete();
+ }
+
+ ///
+ /// Emits a throttled completion_check event for observability.
+ /// Max 1 event per minute per source to avoid flooding.
+ ///
+ private void EmitCompletionCheck(string source, string result, string reason)
+ {
+ // Throttle: max 1x per minute per source
+ var now = DateTime.UtcNow;
+ if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60)
+ return;
+ _lastCompletionCheckBySource[source] = now;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "completion_check",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Completion source '{source}' evaluated — {result}",
+ Data = new Dictionary
+ {
+ { "source", source },
+ { "result", result },
+ { "reason", reason },
+ { "espEverSeen", _espEverSeen },
+ { "espFinalExitSeen", _espFinalExitSeen },
+ { "desktopArrived", _desktopArrived },
+ { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
+ { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
+ { "enrollmentType", _enrollmentType },
+ { "lastEspPhase", _lastEspPhase ?? "none" },
+ { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
+ { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }
+ }
+ });
+ }
+
+ ///
+ /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
+ /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
+ ///
+ public void NotifyDesktopArrived()
+ {
+ if (_desktopArrived)
+ return;
+
+ _desktopArrived = true;
+ _stateData.DesktopArrived = true;
+ _stateData.DesktopArrivedUtc = DateTime.UtcNow;
+ RecordSignal("desktop_arrived");
+ _logger.Info("EnrollmentTracker: Desktop arrival notified");
+
+ // Phase correction: If ESP was seen but AccountSetup was never detected by IME log,
+ // correct the phase and emit an event for the timeline
+ if (_espEverSeen && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ var previousPhase = _lastEspPhase ?? "unknown";
+ _lastEspPhase = "AccountSetup";
+ _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "DesktopArrivalDetector",
+ Phase = EnrollmentPhase.AccountSetup,
+ Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})",
+ Data = new Dictionary
+ {
+ { "espPhase", "AccountSetup" },
+ { "autoDetected", true },
+ { "correctedBy", "desktop_arrival" },
+ { "previousPhase", previousPhase }
+ }
+ });
+ }
+
+ // Start Hello wait timer ONLY when ESP is NOT actively running.
+ // During active ESP (AccountSetup runs in background with desktop visible),
+ // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
+ // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
+ if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
+ && (!_espEverSeen || _espFinalExitSeen))
+ {
+ _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
+ _espAndHelloTracker.StartHelloWaitTimer();
+ }
+
+ TryEmitEnrollmentComplete("desktop_arrival");
+ }
+
+ ///
+ /// Called when the device-only ESP timer expires. If no AccountSetup phase started
+ /// and desktop is available, classify as device-only ESP and mark final exit.
+ ///
+ private void OnDeviceOnlyEspTimerExpired(object state)
+ {
+ // AccountSetup started meanwhile? Timer is obsolete
+ if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring");
+ return;
+ }
+
+ if (_desktopArrived)
+ {
+ _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active");
+ _espFinalExitSeen = true;
+ _stateData.EspFinalExitSeen = true;
+ _stateData.EspFinalExitUtc = DateTime.UtcNow;
+ RecordSignal("device_only_esp_final_exit");
+ _espAndHelloTracker?.StartHelloWaitTimer();
+ }
+ else
+ {
+ _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs
new file mode 100644
index 00000000..fe6e4486
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs
@@ -0,0 +1,327 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
+{
+ ///
+ /// Partial: Device info collection at key lifecycle points, app tracking summaries,
+ /// debug state timer, and final status file writing.
+ ///
+ public partial class EnrollmentTracker
+ {
+ private void CollectDeviceInfoAtEnrollmentStart()
+ {
+ if (_enrollmentStartDeviceInfoCollected)
+ return;
+
+ _enrollmentStartDeviceInfoCollected = true;
+
+ if (!_isBootstrapMode)
+ {
+ _logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
+ return;
+ }
+
+ _logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
+ Task.Run(() =>
+ {
+ try
+ {
+ _deviceInfoCollector.CollectAtEnrollmentStart();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
+ }
+ });
+ }
+
+ private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
+ {
+ if (_finalDeviceInfoCollected)
+ {
+ _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
+ return;
+ }
+
+ _finalDeviceInfoCollected = true;
+ _logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
+ CollectDeviceInfoAtEnd();
+ }
+
+ private void SummaryTimerCallback(object state)
+ {
+ // Only emit if state-breakdown counters changed since last emission (backstop for missed events)
+ EmitAppTrackingSummaryIfChanged();
+
+ // Periodic state save (piggybacks on the existing 30s timer)
+ if (_stateDirty)
+ {
+ _stateDirty = false;
+ _statePersistence.Save(_stateData);
+ }
+ }
+
+ ///
+ /// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
+ /// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
+ ///
+ private void DebugStateTimerCallback(object state)
+ {
+ try
+ {
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
+ var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
+
+ _logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
+ $"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
+ $"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
+ $"active: [{string.Join(", ", active)}], " +
+ $"failed: [{string.Join(", ", errors)}], " +
+ $"phase: {_lastEspPhase ?? "none"}, espExit: {_espFinalExitSeen}, desktop: {_desktopArrived}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"[StateSnapshot] Error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
+ /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
+ ///
+ private void EmitAppTrackingSummaryIfChanged()
+ {
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ var hash = GetSummaryHash(states);
+ if (hash == _lastEmittedSummaryHash) return;
+
+ _lastEmittedSummaryHash = hash;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "app_tracking_summary",
+ Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
+ (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
+ Data = states.GetSummaryData()
+ });
+ }
+
+ ///
+ /// Emits app_tracking_summary unconditionally (for final summary on completion).
+ ///
+ private void EmitAppTrackingSummary()
+ {
+ var states = _imeLogTracker?.PackageStates;
+ if (states == null || states.CountAll == 0) return;
+
+ _lastEmittedSummaryHash = GetSummaryHash(states);
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "app_tracking_summary",
+ Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
+ (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
+ Data = states.GetSummaryData()
+ });
+ }
+
+ private static string GetSummaryHash(AppPackageStateList states)
+ {
+ return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
+ $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
+ }
+
+ public void Dispose()
+ {
+ Stop();
+
+ // Unsubscribe from EspAndHelloTracker events
+ if (_espAndHelloTracker != null)
+ {
+ _espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
+ _espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
+ _espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
+ _espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
+ }
+
+ _espFailureTimer?.Dispose();
+ _deviceOnlyEspTimer?.Dispose();
+ _waitingForHelloSafetyTimer?.Dispose();
+ _summaryTimer?.Dispose();
+ _debugStateTimer?.Dispose();
+ _imeLogTracker?.Dispose();
+ }
+
+ ///
+ /// Writes an enrollment complete marker to the state directory.
+ /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
+ ///
+ private void WriteEnrollmentCompleteMarker()
+ {
+ try
+ {
+ var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
+ Directory.CreateDirectory(stateDirectory);
+
+ var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
+ File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
+
+ _logger.Info($"Enrollment complete marker written: {markerPath}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Writes a comprehensive final-status.json with all app states, signals, and error summary.
+ /// Automatically included in diagnostics uploads for post-mortem analysis.
+ ///
+ private void WriteFinalStatus(string outcome, string source)
+ {
+ try
+ {
+ var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
+ Directory.CreateDirectory(stateDirectory);
+
+ var appStates = _imeLogTracker?.PackageStates;
+
+ var signalTimestamps = new Dictionary();
+ if (_stateData.EspFirstSeenUtc.HasValue)
+ signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
+ if (_stateData.EspFinalExitUtc.HasValue)
+ signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
+ if (_stateData.DesktopArrivedUtc.HasValue)
+ signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
+ if (_stateData.HelloResolvedUtc.HasValue)
+ signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
+ if (_stateData.ImePatternSeenUtc.HasValue)
+ signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
+
+ // Build per-phase package states: snapshots from completed phases + current phase
+ var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
+ var packageStatesByPhase = new Dictionary();
+
+ // Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
+ if (phaseSnapshots != null)
+ {
+ foreach (var kvp in phaseSnapshots)
+ packageStatesByPhase[kvp.Key] = kvp.Value;
+ }
+
+ // Add current phase (the phase active at completion, e.g. AccountSetup)
+ var currentPhaseName = _lastEspPhase ?? "Unknown";
+ var currentPhaseList = appStates?.ToFinalStatusList() ?? new List>();
+ packageStatesByPhase[currentPhaseName] = currentPhaseList;
+
+ // Aggregate app summary across ALL phases (snapshots + current)
+ var currentPhaseTotal = appStates?.CountAll ?? 0;
+ var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
+ var currentPhaseErrors = appStates?.ErrorCount ?? 0;
+ var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
+ var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
+
+ var allPhaseTotals = new Dictionary();
+ var totalAppsAllPhases = currentPhaseTotal;
+ var completedAppsAllPhases = currentPhaseCompleted;
+ var errorCountAllPhases = currentPhaseErrors;
+ var deviceErrorsAllPhases = currentPhaseDeviceErrors;
+ var userErrorsAllPhases = currentPhaseUserErrors;
+
+ allPhaseTotals[currentPhaseName] = currentPhaseTotal;
+
+ if (phaseSnapshots != null)
+ {
+ foreach (var kvp in phaseSnapshots)
+ {
+ var phaseApps = kvp.Value;
+ allPhaseTotals[kvp.Key] = phaseApps.Count;
+ totalAppsAllPhases += phaseApps.Count;
+ foreach (var app in phaseApps)
+ {
+ object stateVal;
+ var isError = app.TryGetValue("state", out stateVal) &&
+ string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
+ var isCompleted = app.TryGetValue("state", out stateVal) &&
+ (stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
+ stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
+ if (isCompleted) completedAppsAllPhases++;
+ if (isError)
+ {
+ errorCountAllPhases++;
+ object targetVal;
+ if (app.TryGetValue("targeted", out targetVal))
+ {
+ if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
+ deviceErrorsAllPhases++;
+ else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
+ userErrorsAllPhases++;
+ }
+ }
+ }
+ }
+ }
+
+ var status = new Dictionary
+ {
+ { "timestamp", DateTime.UtcNow.ToString("o") },
+ { "outcome", outcome },
+ { "completionSource", source },
+ { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
+ { "enrollmentType", _enrollmentType ?? "unknown" },
+ { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
+ { "signalsSeen", _stateData.SignalsSeen ?? new List() },
+ { "signalTimestamps", signalTimestamps },
+ { "appSummary", new Dictionary
+ {
+ { "totalApps", totalAppsAllPhases },
+ { "completedApps", completedAppsAllPhases },
+ { "errorCount", errorCountAllPhases },
+ { "deviceErrors", deviceErrorsAllPhases },
+ { "userErrors", userErrorsAllPhases },
+ { "appsByPhase", allPhaseTotals }
+ }
+ },
+ { "packageStatesByPhase", packageStatesByPhase }
+ };
+
+ var json = System.Text.Json.JsonSerializer.Serialize(status,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ var path = Path.Combine(stateDirectory, "final-status.json");
+ File.WriteAllText(path, json);
+ _logger.Info($"Final status written: {path}");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Failed to write final status: {ex.Message}");
+ }
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs
new file mode 100644
index 00000000..5dd461d7
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs
@@ -0,0 +1,467 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
+{
+ ///
+ /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
+ ///
+ public partial class EnrollmentTracker
+ {
+ private void HandleEspPhaseChanged(string phase)
+ {
+ // WDP (v2) has no ESP - skip ESP phase handling entirely
+ if (_enrollmentType == "v2")
+ {
+ _logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})");
+ return;
+ }
+
+ // Only emit event if the phase has actually changed
+ if (string.Equals(phase, _lastEspPhase, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
+ return;
+ }
+
+ _logger.Info($"EnrollmentTracker: ESP phase changed from '{_lastEspPhase ?? "null"}' to '{phase}'");
+ _lastEspPhase = phase;
+ _hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
+ _espEverSeen = true;
+ _stateData.EspEverSeen = true;
+ _stateData.LastEspPhase = phase;
+ if (_stateData.EspFirstSeenUtc == null)
+ _stateData.EspFirstSeenUtc = DateTime.UtcNow;
+ RecordSignal($"esp_phase_{phase}");
+
+ // ESP phase change means ESP is progressing — cancel any pending failure grace period
+ CancelPendingEspFailure();
+
+ // Cancel device-only ESP timer if AccountSetup phase detected
+ if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ if (_deviceOnlyEspTimer != null)
+ {
+ _logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
+ _deviceOnlyEspTimer.Dispose();
+ _deviceOnlyEspTimer = null;
+ }
+ }
+
+ // Map ESP phase to EnrollmentPhase (phase change events)
+ var enrollmentPhase = EnrollmentPhase.DeviceSetup;
+ if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ enrollmentPhase = EnrollmentPhase.AccountSetup;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = enrollmentPhase,
+ Message = $"ESP phase: {phase}",
+ Data = new Dictionary { { "espPhase", phase } }
+ });
+
+ // Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
+ // that may have been empty at startup (bootstrap / pre-enrollment scenario).
+ CollectDeviceInfoAtEnrollmentStart();
+
+ // Start summary timer when we detect ESP phase
+ if (!_summaryTimerActive)
+ {
+ _summaryTimerActive = true;
+ _summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
+ }
+ if (!_debugStateTimerActive)
+ {
+ _debugStateTimerActive = true;
+ _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
+ }
+ }
+
+ private void HandleImeAgentVersion(string version)
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "ime_agent_version",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"IME Agent version: {version}",
+ Data = new Dictionary { { "agentVersion", version } }
+ });
+ }
+
+ private void HandleImeStarted()
+ {
+ _logger.Info("EnrollmentTracker: IME started event");
+ }
+
+ private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
+ {
+ // Auto-switch to app installation phase when first app activity detected
+ // If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
+ // If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
+ if (!_hasAutoSwitchedToAppsPhase &&
+ (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
+ oldState < AppInstallationState.Downloading)
+ {
+ if (_lastEspPhase != null)
+ {
+ if (string.Equals(_lastEspPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // Switch from DeviceSetup to AppsDevice
+ _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
+ _hasAutoSwitchedToAppsPhase = true;
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AppsDevice,
+ Message = "ESP phase: AppsDevice (auto-detected from app activity)",
+ Data = new Dictionary { { "espPhase", "AppsDevice" }, { "autoDetected", true } }
+ });
+ }
+ else if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
+ {
+ // Switch from AccountSetup to AppsUser
+ _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
+ _hasAutoSwitchedToAppsPhase = true;
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_phase_changed",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.AppsUser,
+ Message = "ESP phase: AppsUser (auto-detected from app activity)",
+ Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } }
+ });
+ }
+ }
+ }
+
+ // Only emit strategic events for significant state transitions
+ string eventType;
+ var severity = EventSeverity.Info;
+ var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
+
+ switch (newState)
+ {
+ case AppInstallationState.Downloading:
+ // Emit strategic event once when download starts
+ if (oldState < AppInstallationState.Downloading)
+ {
+ eventType = "app_download_started";
+ }
+ else
+ {
+ // Emit debug event for download progress updates
+ // Skip if no real download data (bytesTotal too small or zero)
+ if (app.BytesTotal > 1024) // At least 1 KB to be a real download
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
+ Data = app.ToEventData()
+ });
+ }
+ return; // Skip main event emission below
+ }
+ break;
+
+ case AppInstallationState.Installing:
+ // Only emit the strategic event once when install actually starts.
+ // Progress-only updates (oldState == Installing, just progress/bytes changed)
+ // are skipped — same pattern as Downloading above.
+ if (oldState == AppInstallationState.Installing)
+ return;
+ eventType = "app_install_started";
+ break;
+
+ case AppInstallationState.Installed:
+ eventType = "app_install_completed";
+ // Emit download_progress event for download manager (shows as completed)
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: completed",
+ Data = new Dictionary(app.ToEventData())
+ {
+ ["status"] = "completed"
+ }
+ });
+ break;
+
+ case AppInstallationState.Skipped:
+ eventType = "app_install_skipped";
+ break;
+
+ case AppInstallationState.Error:
+ eventType = "app_install_failed";
+ severity = EventSeverity.Error;
+ // Emit download_progress event for download manager (shows as failed)
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: failed",
+ Data = new Dictionary(app.ToEventData())
+ {
+ ["status"] = "failed"
+ }
+ });
+ break;
+
+ case AppInstallationState.Postponed:
+ eventType = "app_install_failed";
+ severity = EventSeverity.Warning;
+ break;
+
+ default:
+ return; // Don't emit for Unknown, NotInstalled, InProgress
+ }
+
+ // Build a descriptive message: include error detail if available
+ var message = $"{app.Name ?? app.Id}: {newState}";
+ if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
+ message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = severity,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = message,
+ Data = app.ToEventData()
+ });
+
+ // Emit summary immediately if state-breakdown counters changed (instant UI updates)
+ EmitAppTrackingSummaryIfChanged();
+ }
+
+ private void HandlePoliciesDiscovered(string policiesJson)
+ {
+ _logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
+ }
+
+ private void HandleAllAppsCompleted()
+ {
+ _logger.Info("EnrollmentTracker: all apps completed");
+
+ // Stop timers
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ // Emit final summary
+ EmitAppTrackingSummary();
+
+ // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
+ // (ESP exit or Hello wizard start) for more robust detection.
+ // We no longer automatically transition here when apps complete.
+ if (_lastEspPhase != null)
+ {
+ _logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'");
+ _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
+ }
+ }
+
+ private void HandleDoTelemetryReceived(AppPackageState app)
+ {
+ _logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
+
+ var phase = EnrollmentPhase.Unknown;
+
+ // Dedicated do_telemetry event for backend aggregation
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "do_telemetry",
+ Severity = EventSeverity.Info,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
+ Data = app.ToEventData()
+ });
+
+ // Also emit download_progress so the UI picks up DO stats
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "download_progress",
+ Severity = EventSeverity.Debug,
+ Source = "ImeLogTracker",
+ Phase = phase,
+ Message = $"{app.Name ?? app.Id}: DO telemetry received",
+ Data = app.ToEventData()
+ });
+ }
+
+ private void HandleScriptCompleted(ScriptExecutionState script)
+ {
+ if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
+
+ var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
+ || (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
+
+ var eventType = isSuccess
+ ? Constants.EventTypes.ScriptCompleted
+ : Constants.EventTypes.ScriptFailed;
+
+ var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
+
+ // Build human-readable message
+ string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
+ string message;
+ if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
+ {
+ var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
+ ? "Compliant" : "Non-compliant";
+ message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
+ }
+ else
+ {
+ message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
+ }
+
+ // Add stderr hint to message if present
+ if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
+ message += " - stderr present";
+
+ var data = new Dictionary
+ {
+ ["policyId"] = script.PolicyId,
+ ["scriptType"] = script.ScriptType ?? "platform",
+ ["exitCode"] = script.ExitCode ?? -1
+ };
+
+ if (!string.IsNullOrEmpty(script.RunContext))
+ data["runContext"] = script.RunContext;
+ if (!string.IsNullOrEmpty(script.Result))
+ data["result"] = script.Result;
+ if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
+ data["stdout"] = script.Stdout;
+ if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
+ data["stderr"] = script.Stderr;
+ if (!string.IsNullOrEmpty(script.ComplianceResult))
+ data["complianceResult"] = script.ComplianceResult;
+ if (!string.IsNullOrEmpty(script.ScriptPart))
+ data["scriptPart"] = script.ScriptPart;
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = severity,
+ Source = Constants.EventSources.IME,
+ Phase = EnrollmentPhase.Unknown,
+ Message = message,
+ Data = data
+ });
+
+ _logger.Info($"EnrollmentTracker: {message}");
+ }
+
+ private void HandleUserSessionCompleted()
+ {
+ _logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
+ _stateData.ImePatternSeenUtc = DateTime.UtcNow;
+ RecordSignal("ime_pattern");
+
+ // User session completed successfully — cancel any pending ESP failure
+ CancelPendingEspFailure();
+
+ // Stop timers if running
+ _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _summaryTimerActive = false;
+ _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+ _debugStateTimerActive = false;
+
+ // Check if Windows Hello is configured but not yet completed
+ if (_espAndHelloTracker != null)
+ {
+ bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
+ bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
+
+ if (helloPolicyConfigured && !helloCompleted)
+ {
+ // Hello is configured but not finished yet - DO NOT mark enrollment as complete
+ _logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
+ _logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
+
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "waiting_for_hello",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTracker",
+ Phase = EnrollmentPhase.FinalizingSetup,
+ Message = "User apps completed - waiting for Windows Hello provisioning to finish"
+ });
+
+ // Set flag so we know we're waiting
+ _isWaitingForHello = true;
+ _stateData.IsWaitingForHello = true;
+ _stateDirty = true;
+ _statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
+
+ // Defense-in-depth: Ensure Hello wait timer is running.
+ // StartHelloWaitTimer() has internal guards (returns if timer already running,
+ // if Hello already completed, or if wizard already started). Safe to call multiple times.
+ _espAndHelloTracker?.StartHelloWaitTimer();
+
+ // Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
+ // force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
+ _waitingForHelloSafetyTimer?.Dispose();
+ _waitingForHelloSafetyTimer = new Timer(
+ OnWaitingForHelloSafetyTimeout,
+ null,
+ TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
+ TimeSpan.FromMilliseconds(-1));
+
+ return;
+ }
+ }
+
+ TryEmitEnrollmentComplete("ime_pattern");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
index 7a9ad40b..4ece92e1 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// Collects consolidated device info events at startup and manages ImeLogTracker
/// for smart app installation tracking with strategic event emission.
///
- public class EnrollmentTracker : IDisposable
+ public partial class EnrollmentTracker : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
@@ -267,1345 +267,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
private void CollectDeviceInfoAtEnd()
=> _deviceInfoCollector.CollectAtEnd();
- // ===== ImeLogTracker Callbacks -> Strategic Events =====
-
- private void HandleEspPhaseChanged(string phase)
- {
- // WDP (v2) has no ESP - skip ESP phase handling entirely
- if (_enrollmentType == "v2")
- {
- _logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})");
- return;
- }
-
- // Only emit event if the phase has actually changed
- if (string.Equals(phase, _lastEspPhase, StringComparison.OrdinalIgnoreCase))
- {
- _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
- return;
- }
-
- _logger.Info($"EnrollmentTracker: ESP phase changed from '{_lastEspPhase ?? "null"}' to '{phase}'");
- _lastEspPhase = phase;
- _hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
- _espEverSeen = true;
- _stateData.EspEverSeen = true;
- _stateData.LastEspPhase = phase;
- if (_stateData.EspFirstSeenUtc == null)
- _stateData.EspFirstSeenUtc = DateTime.UtcNow;
- RecordSignal($"esp_phase_{phase}");
-
- // ESP phase change means ESP is progressing — cancel any pending failure grace period
- CancelPendingEspFailure();
-
- // Cancel device-only ESP timer if AccountSetup phase detected
- if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- if (_deviceOnlyEspTimer != null)
- {
- _logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
- _deviceOnlyEspTimer.Dispose();
- _deviceOnlyEspTimer = null;
- }
- }
-
- // Map ESP phase to EnrollmentPhase (phase change events)
- var enrollmentPhase = EnrollmentPhase.DeviceSetup;
- if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- enrollmentPhase = EnrollmentPhase.AccountSetup;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = enrollmentPhase,
- Message = $"ESP phase: {phase}",
- Data = new Dictionary { { "espPhase", phase } }
- });
-
- // Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
- // that may have been empty at startup (bootstrap / pre-enrollment scenario).
- CollectDeviceInfoAtEnrollmentStart();
-
- // Start summary timer when we detect ESP phase
- if (!_summaryTimerActive)
- {
- _summaryTimerActive = true;
- _summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
- }
- if (!_debugStateTimerActive)
- {
- _debugStateTimerActive = true;
- _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
- }
- }
-
- private void HandleImeAgentVersion(string version)
- {
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "ime_agent_version",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"IME Agent version: {version}",
- Data = new Dictionary { { "agentVersion", version } }
- });
- }
-
- private void HandleImeStarted()
- {
- _logger.Info("EnrollmentTracker: IME started event");
- }
-
- private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
- {
- // Auto-switch to app installation phase when first app activity detected
- // If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
- // If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
- if (!_hasAutoSwitchedToAppsPhase &&
- (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
- oldState < AppInstallationState.Downloading)
- {
- if (_lastEspPhase != null)
- {
- if (string.Equals(_lastEspPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
- {
- // Switch from DeviceSetup to AppsDevice
- _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
- _hasAutoSwitchedToAppsPhase = true;
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AppsDevice,
- Message = "ESP phase: AppsDevice (auto-detected from app activity)",
- Data = new Dictionary { { "espPhase", "AppsDevice" }, { "autoDetected", true } }
- });
- }
- else if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- // Switch from AccountSetup to AppsUser
- _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
- _hasAutoSwitchedToAppsPhase = true;
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.AppsUser,
- Message = "ESP phase: AppsUser (auto-detected from app activity)",
- Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } }
- });
- }
- }
- }
-
- // Only emit strategic events for significant state transitions
- string eventType;
- var severity = EventSeverity.Info;
- var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
-
- switch (newState)
- {
- case AppInstallationState.Downloading:
- // Emit strategic event once when download starts
- if (oldState < AppInstallationState.Downloading)
- {
- eventType = "app_download_started";
- }
- else
- {
- // Emit debug event for download progress updates
- // Skip if no real download data (bytesTotal too small or zero)
- if (app.BytesTotal > 1024) // At least 1 KB to be a real download
- {
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
- Data = app.ToEventData()
- });
- }
- return; // Skip main event emission below
- }
- break;
-
- case AppInstallationState.Installing:
- // Only emit the strategic event once when install actually starts.
- // Progress-only updates (oldState == Installing, just progress/bytes changed)
- // are skipped — same pattern as Downloading above.
- if (oldState == AppInstallationState.Installing)
- return;
- eventType = "app_install_started";
- break;
-
- case AppInstallationState.Installed:
- eventType = "app_install_completed";
- // Emit download_progress event for download manager (shows as completed)
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: completed",
- Data = new Dictionary(app.ToEventData())
- {
- ["status"] = "completed"
- }
- });
- break;
-
- case AppInstallationState.Skipped:
- eventType = "app_install_skipped";
- break;
-
- case AppInstallationState.Error:
- eventType = "app_install_failed";
- severity = EventSeverity.Error;
- // Emit download_progress event for download manager (shows as failed)
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: failed",
- Data = new Dictionary(app.ToEventData())
- {
- ["status"] = "failed"
- }
- });
- break;
-
- case AppInstallationState.Postponed:
- eventType = "app_install_failed";
- severity = EventSeverity.Warning;
- break;
-
- default:
- return; // Don't emit for Unknown, NotInstalled, InProgress
- }
-
- // Build a descriptive message: include error detail if available
- var message = $"{app.Name ?? app.Id}: {newState}";
- if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
- message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = eventType,
- Severity = severity,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = message,
- Data = app.ToEventData()
- });
-
- // Emit summary immediately if state-breakdown counters changed (instant UI updates)
- EmitAppTrackingSummaryIfChanged();
- }
-
- private void HandlePoliciesDiscovered(string policiesJson)
- {
- _logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
- }
-
- private void HandleAllAppsCompleted()
- {
- _logger.Info("EnrollmentTracker: all apps completed");
-
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- // Emit final summary
- EmitAppTrackingSummary();
-
- // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
- // (ESP exit or Hello wizard start) for more robust detection.
- // We no longer automatically transition here when apps complete.
- if (_lastEspPhase != null)
- {
- _logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'");
- _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
- }
- }
-
- private void HandleDoTelemetryReceived(AppPackageState app)
- {
- _logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
-
- var phase = EnrollmentPhase.Unknown;
-
- // Dedicated do_telemetry event for backend aggregation
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "do_telemetry",
- Severity = EventSeverity.Info,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
- Data = app.ToEventData()
- });
-
- // Also emit download_progress so the UI picks up DO stats
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "download_progress",
- Severity = EventSeverity.Debug,
- Source = "ImeLogTracker",
- Phase = phase,
- Message = $"{app.Name ?? app.Id}: DO telemetry received",
- Data = app.ToEventData()
- });
- }
-
- private void HandleScriptCompleted(ScriptExecutionState script)
- {
- if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
-
- var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
- || (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
-
- var eventType = isSuccess
- ? Constants.EventTypes.ScriptCompleted
- : Constants.EventTypes.ScriptFailed;
-
- var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
-
- // Build human-readable message
- string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
- string message;
- if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
- {
- var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
- ? "Compliant" : "Non-compliant";
- message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
- }
- else
- {
- message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
- }
-
- // Add stderr hint to message if present
- if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
- message += " - stderr present";
-
- var data = new Dictionary
- {
- ["policyId"] = script.PolicyId,
- ["scriptType"] = script.ScriptType ?? "platform",
- ["exitCode"] = script.ExitCode ?? -1
- };
-
- if (!string.IsNullOrEmpty(script.RunContext))
- data["runContext"] = script.RunContext;
- if (!string.IsNullOrEmpty(script.Result))
- data["result"] = script.Result;
- if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
- data["stdout"] = script.Stdout;
- if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
- data["stderr"] = script.Stderr;
- if (!string.IsNullOrEmpty(script.ComplianceResult))
- data["complianceResult"] = script.ComplianceResult;
- if (!string.IsNullOrEmpty(script.ScriptPart))
- data["scriptPart"] = script.ScriptPart;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = eventType,
- Severity = severity,
- Source = Constants.EventSources.IME,
- Phase = EnrollmentPhase.Unknown,
- Message = message,
- Data = data
- });
-
- _logger.Info($"EnrollmentTracker: {message}");
- }
-
- private void HandleUserSessionCompleted()
- {
- _logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
- _stateData.ImePatternSeenUtc = DateTime.UtcNow;
- RecordSignal("ime_pattern");
-
- // User session completed successfully — cancel any pending ESP failure
- CancelPendingEspFailure();
-
- // Stop timers if running
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- // Check if Windows Hello is configured but not yet completed
- if (_espAndHelloTracker != null)
- {
- bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
- bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
-
- if (helloPolicyConfigured && !helloCompleted)
- {
- // Hello is configured but not finished yet - DO NOT mark enrollment as complete
- _logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
- _logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "waiting_for_hello",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "User apps completed - waiting for Windows Hello provisioning to finish"
- });
-
- // Set flag so we know we're waiting
- _isWaitingForHello = true;
- _stateData.IsWaitingForHello = true;
- _stateDirty = true;
- _statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
-
- // Defense-in-depth: Ensure Hello wait timer is running.
- // StartHelloWaitTimer() has internal guards (returns if timer already running,
- // if Hello already completed, or if wizard already started). Safe to call multiple times.
- _espAndHelloTracker?.StartHelloWaitTimer();
-
- // Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
- // force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForHelloSafetyTimer = new Timer(
- OnWaitingForHelloSafetyTimeout,
- null,
- TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
-
- return;
- }
- }
-
- TryEmitEnrollmentComplete("ime_pattern");
- }
-
- ///
- /// Called when Windows Hello provisioning completes (via EspAndHelloTracker event).
- /// Multiple completion paths: IME pattern was waiting for Hello, ESP final exit + Hello composite,
- /// or Desktop arrival + Hello.
- ///
- private void OnHelloCompleted(object sender, EventArgs e)
- {
- _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker");
- _stateData.HelloResolvedUtc = DateTime.UtcNow;
- RecordSignal("hello_resolved");
-
- // Cancel safety timer — Hello resolved normally
- _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite);
-
- if (_isWaitingForHello)
- {
- _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now");
- _isWaitingForHello = false;
- TryEmitEnrollmentComplete("ime_hello");
- }
- else if (_espFinalExitSeen)
- {
- _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion");
- TryEmitEnrollmentComplete("esp_hello_composite");
- }
- else if (_desktopArrived)
- {
- _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion");
- TryEmitEnrollmentComplete("desktop_hello");
- }
- else
- {
- _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet");
- }
- }
-
- ///
- /// Called when WhiteGlove (Pre-Provisioning) completes successfully.
- /// Emits the whiteglove_complete event. The MonitoringService handles
- /// the actual agent shutdown upon seeing this event type.
- ///
- private void OnWhiteGloveCompleted(object sender, EventArgs e)
- {
- _logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed");
-
- // Stop summary timer — no more app tracking needed
- _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
- _summaryTimerActive = false;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "whiteglove_complete",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state"
- });
-
- // WICHTIG: Kein WriteEnrollmentCompleteMarker()!
- // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft.
- // WICHTIG: Kein _imeLogTracker?.DeleteState()!
- // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss.
- }
-
- ///
- /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.).
- /// Terminal failures emit enrollment_failed immediately.
- /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure.
- ///
- private void OnEspFailureDetected(object sender, string failureType)
- {
- _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}");
-
- if (RecoverableEspFailureTypes.Contains(failureType))
- {
- // Recoverable failure — start grace period timer
- _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period");
- _pendingEspFailureType = failureType;
-
- // Cancel existing timer if any (e.g. second timeout event)
- _espFailureTimer?.Dispose();
- _espFailureTimer = new Timer(
- OnEspFailureGracePeriodExpired,
- null,
- TimeSpan.FromSeconds(EspFailureGracePeriodSeconds),
- TimeSpan.FromMilliseconds(-1));
- }
- else
- {
- // Terminal failure — emit enrollment_failed immediately
- _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately");
- EmitEnrollmentFailed(failureType, "esp_failure");
- }
- }
-
- ///
- /// Called when the ESP failure grace period expires without recovery.
- /// Emits enrollment_failed.
- ///
- private void OnEspFailureGracePeriodExpired(object state)
- {
- var failureType = _pendingEspFailureType ?? "unknown";
- _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed");
- _pendingEspFailureType = null;
-
- EmitEnrollmentFailed(failureType, "esp_failure_grace_expired");
- }
-
- ///
- /// Cancels any pending ESP failure grace period timer (called when recovery is detected).
- ///
- private void CancelPendingEspFailure()
- {
- if (_espFailureTimer != null)
- {
- _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{_pendingEspFailureType}'");
- _espFailureTimer.Dispose();
- _espFailureTimer = null;
- _pendingEspFailureType = null;
- }
- }
-
- ///
- /// Safety-net callback: fires 7 minutes after waiting_for_hello was set.
- /// If the normal Hello timeout chain (30s + 300s) failed for any reason,
- /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer.
- ///
- private void OnWaitingForHelloSafetyTimeout(object state)
- {
- if (!_isWaitingForHello || _enrollmentCompleteEmitted)
- return;
-
- _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion");
- _isWaitingForHello = false;
-
- // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes
- if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
- {
- _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout");
- }
-
- TryEmitEnrollmentComplete("ime_hello_safety_timeout");
- }
-
- ///
- /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete.
- ///
- private void EmitEnrollmentFailed(string failureType, string failureSource)
- {
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_failed",
- Severity = EventSeverity.Error,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = $"Autopilot enrollment failed: {failureType}",
- Data = new Dictionary
- {
- { "failureType", failureType },
- { "failureSource", failureSource },
- { "signalsSeen", _stateData.SignalsSeen },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
- }
- });
-
- // Write final status dump before cleanup
- WriteFinalStatus("failed", failureType);
-
- // Clean up persisted tracker state
- _imeLogTracker?.DeleteState();
- _statePersistence.Delete();
- }
-
- ///
- /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events)
- /// Triggers transition to FinalizingSetup phase
- ///
- private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
- {
- _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}");
-
- // If ESP exiting, check which phase we're in
- if (reason == "esp_exiting")
- {
- _espEverSeen = true;
-
- if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) || _desktopArrived)
- {
- // Final ESP exit: either AccountSetup phase detected OR desktop arrived (backup)
- var phaseInfo = string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
- ? "AccountSetup"
- : $"{_lastEspPhase ?? "unknown"} (desktop arrival backup)";
- _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer");
-
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- RecordSignal("esp_final_exit");
-
- // Emit phase change event to FinalizingSetup
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", reason },
- { "previousPhase", _lastEspPhase ?? "unknown" },
- { "desktopArrivedBackup", _desktopArrived && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) }
- }
- });
-
- CollectDeviceInfoAtFinalizingSetup(reason);
-
- // Start Hello wait timer (waits for Hello wizard to start or timeout)
- _espAndHelloTracker?.StartHelloWaitTimer();
-
- // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301
- // during AccountSetup), the composite signal can fire immediately.
- TryEmitEnrollmentComplete("esp_hello_composite");
- }
- else if (_skipUserStatusPage == true)
- {
- // Registry definitively says no AccountSetup expected → immediate device-only classification
- _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)");
-
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- RecordSignal("device_only_esp_registry");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", "device_only_esp_registry" },
- { "previousPhase", _lastEspPhase ?? "unknown" },
- { "skipUserStatusPage", true }
- }
- });
-
- CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry");
- _espAndHelloTracker?.StartHelloWaitTimer();
- TryEmitEnrollmentComplete("device_only_esp_registry");
- }
- else
- {
- // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection
- var fallbackReason = _skipUserStatusPage == null ? "registry keys not found" : "SkipUserStatusPage=false";
- _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)");
-
- // Start device-only ESP detection timer
- _deviceOnlyEspTimer?.Dispose();
- _deviceOnlyEspTimer = new Timer(
- OnDeviceOnlyEspTimerExpired,
- null,
- TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes),
- TimeSpan.FromMilliseconds(-1));
- }
- }
- else if (reason == "hello_wizard_started")
- {
- // Hello wizard started - transition to FinalizingSetup regardless of previous phase
- _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.FinalizingSetup,
- Message = "ESP phase: FinalizingSetup (Hello wizard started)",
- Data = new Dictionary
- {
- { "espPhase", "FinalizingSetup" },
- { "autoDetected", true },
- { "triggeredBy", reason }
- }
- });
-
- CollectDeviceInfoAtFinalizingSetup(reason);
- }
- }
-
- // ===== State Persistence =====
-
- private void LoadState()
- {
- var loaded = _statePersistence.Load();
- if (loaded == null)
- return;
-
- _espEverSeen = loaded.EspEverSeen;
- _espFinalExitSeen = loaded.EspFinalExitSeen;
- _desktopArrived = loaded.DesktopArrived;
- _lastEspPhase = loaded.LastEspPhase;
- _isWaitingForHello = loaded.IsWaitingForHello;
- _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted;
- _enrollmentType = loaded.EnrollmentType ?? _enrollmentType;
- _skipUserStatusPage = loaded.SkipUserStatusPage;
- _skipDeviceStatusPage = loaded.SkipDeviceStatusPage;
- _stateData = loaded;
-
- _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}");
-
- // Restart Hello wait timer if needed after crash recovery
- if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted)
- {
- _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery");
- _espAndHelloTracker.StartHelloWaitTimer();
- }
-
- // Restart safety timer if we were waiting for Hello
- if (_isWaitingForHello && !_enrollmentCompleteEmitted)
- {
- _logger.Info("EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery");
- _waitingForHelloSafetyTimer?.Dispose();
- _waitingForHelloSafetyTimer = new Timer(
- OnWaitingForHelloSafetyTimeout,
- null,
- TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
- TimeSpan.FromMilliseconds(-1));
- }
- }
-
- private void RecordSignal(string signal)
- {
- if (!_stateData.SignalsSeen.Contains(signal))
- _stateData.SignalsSeen.Add(signal);
- _stateDirty = true;
- }
-
- // ===== Unified Completion Logic =====
-
- ///
- /// Central guard method for enrollment_complete emission. All completion paths route through here.
- /// An _enrollmentCompleteEmitted flag prevents double emission.
- /// Emits a throttled completion_check event on every call for observability.
- ///
- private void TryEmitEnrollmentComplete(string source)
- {
- if (_enrollmentCompleteEmitted)
- {
- _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping");
- return;
- }
-
- if (string.IsNullOrEmpty(source))
- return;
-
- // Hello-Check: Hello must be resolved before we can complete
- bool helloResolved = _espAndHelloTracker == null
- || _espAndHelloTracker.IsHelloCompleted
- || !_espAndHelloTracker.IsPolicyConfigured;
-
- if (!helloResolved)
- {
- _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet");
- EmitCompletionCheck(source, "hello_pending", "hello_not_resolved");
- return;
- }
-
- // Desktop-Arrival Gate: Block desktop-based completion when ESP is still actively running.
- // Both "desktop_arrival" (direct) and "desktop_hello" (Hello resolved after desktop arrival)
- // must be gated — otherwise Hello timeout during active AccountSetup triggers premature completion.
- // WDP v2 has no ESP — skip the gate entirely.
- if ((source == "desktop_arrival" || source == "desktop_hello") && _enrollmentType != "v2" && _espEverSeen && !_espFinalExitSeen)
- {
- _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active");
- EmitCompletionCheck(source, "blocked", "esp_active");
- return;
- }
-
- _enrollmentCompleteEmitted = true;
- _stateData.EnrollmentCompleteEmitted = true;
-
- // Stop timers
- _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _summaryTimerActive = false;
- _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
- _debugStateTimerActive = false;
-
- var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown";
-
- var signalTimestamps = new Dictionary();
- if (_stateData.EspFirstSeenUtc.HasValue)
- signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
- if (_stateData.DesktopArrivedUtc.HasValue)
- signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
- if (_stateData.EspFinalExitUtc.HasValue)
- signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
- if (_stateData.HelloResolvedUtc.HasValue)
- signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
- if (_stateData.ImePatternSeenUtc.HasValue)
- signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "enrollment_complete",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Complete,
- Message = $"Autopilot enrollment completed successfully (source: {source})",
- Data = new Dictionary
- {
- { "completionSource", source },
- { "helloOutcome", helloOutcome },
- { "signalsSeen", _stateData.SignalsSeen },
- { "signalTimestamps", signalTimestamps },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }
- }
- });
-
- // Write final status dump and enrollment complete marker
- WriteFinalStatus("completed", source);
- WriteEnrollmentCompleteMarker();
-
- // Clean up persisted tracker state so next enrollment starts fresh
- _imeLogTracker?.DeleteState();
- _statePersistence.Delete();
- }
-
- ///
- /// Emits a throttled completion_check event for observability.
- /// Max 1 event per minute per source to avoid flooding.
- ///
- private void EmitCompletionCheck(string source, string result, string reason)
- {
- // Throttle: max 1x per minute per source
- var now = DateTime.UtcNow;
- if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60)
- return;
- _lastCompletionCheckBySource[source] = now;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "completion_check",
- Severity = EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"Completion source '{source}' evaluated — {result}",
- Data = new Dictionary
- {
- { "source", source },
- { "result", result },
- { "reason", reason },
- { "espEverSeen", _espEverSeen },
- { "espFinalExitSeen", _espFinalExitSeen },
- { "desktopArrived", _desktopArrived },
- { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false },
- { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false },
- { "enrollmentType", _enrollmentType },
- { "lastEspPhase", _lastEspPhase ?? "none" },
- { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" },
- { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }
- }
- });
- }
-
- ///
- /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user).
- /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion.
- ///
- public void NotifyDesktopArrived()
- {
- if (_desktopArrived)
- return;
-
- _desktopArrived = true;
- _stateData.DesktopArrived = true;
- _stateData.DesktopArrivedUtc = DateTime.UtcNow;
- RecordSignal("desktop_arrived");
- _logger.Info("EnrollmentTracker: Desktop arrival notified");
-
- // Phase correction: If ESP was seen but AccountSetup was never detected by IME log,
- // correct the phase and emit an event for the timeline
- if (_espEverSeen && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- var previousPhase = _lastEspPhase ?? "unknown";
- _lastEspPhase = "AccountSetup";
- _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline");
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "esp_phase_changed",
- Severity = EventSeverity.Info,
- Source = "DesktopArrivalDetector",
- Phase = EnrollmentPhase.AccountSetup,
- Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})",
- Data = new Dictionary
- {
- { "espPhase", "AccountSetup" },
- { "autoDetected", true },
- { "correctedBy", "desktop_arrival" },
- { "previousPhase", previousPhase }
- }
- });
- }
-
- // Start Hello wait timer ONLY when ESP is NOT actively running.
- // During active ESP (AccountSetup runs in background with desktop visible),
- // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered).
- // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion.
- if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted
- && (!_espEverSeen || _espFinalExitSeen))
- {
- _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer");
- _espAndHelloTracker.StartHelloWaitTimer();
- }
-
- TryEmitEnrollmentComplete("desktop_arrival");
- }
-
- ///
- /// Called when the device-only ESP timer expires. If no AccountSetup phase started
- /// and desktop is available, classify as device-only ESP and mark final exit.
- ///
- private void OnDeviceOnlyEspTimerExpired(object state)
- {
- // AccountSetup started meanwhile? Timer is obsolete
- if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring");
- return;
- }
-
- if (_desktopArrived)
- {
- _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active");
- _espFinalExitSeen = true;
- _stateData.EspFinalExitSeen = true;
- _stateData.EspFinalExitUtc = DateTime.UtcNow;
- RecordSignal("device_only_esp_final_exit");
- _espAndHelloTracker?.StartHelloWaitTimer();
- }
- else
- {
- _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer");
- }
- }
-
- ///
- /// Re-collects enrollment-dependent device info (AAD join, Autopilot profile, ESP config)
- /// that were likely empty when the agent started before enrollment (bootstrap scenario).
- /// Only runs in bootstrap mode — in normal mode CollectAll() already has complete data.
- /// Called once when the first ESP phase is detected.
- ///
- private void CollectDeviceInfoAtEnrollmentStart()
- {
- if (_enrollmentStartDeviceInfoCollected)
- return;
-
- _enrollmentStartDeviceInfoCollected = true;
-
- if (!_isBootstrapMode)
- {
- _logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
- return;
- }
-
- _logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
- Task.Run(() =>
- {
- try
- {
- _deviceInfoCollector.CollectAtEnrollmentStart();
- }
- catch (Exception ex)
- {
- _logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
- }
- });
- }
-
- private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
- {
- if (_finalDeviceInfoCollected)
- {
- _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
- return;
- }
-
- _finalDeviceInfoCollected = true;
- _logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
- CollectDeviceInfoAtEnd();
- }
-
- private void SummaryTimerCallback(object state)
- {
- // Only emit if state-breakdown counters changed since last emission (backstop for missed events)
- EmitAppTrackingSummaryIfChanged();
-
- // Periodic state save (piggybacks on the existing 30s timer)
- if (_stateDirty)
- {
- _stateDirty = false;
- _statePersistence.Save(_stateData);
- }
- }
-
- ///
- /// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
- /// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
- ///
- private void DebugStateTimerCallback(object state)
- {
- try
- {
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
- var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
-
- _logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
- $"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
- $"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
- $"active: [{string.Join(", ", active)}], " +
- $"failed: [{string.Join(", ", errors)}], " +
- $"phase: {_lastEspPhase ?? "none"}, espExit: {_espFinalExitSeen}, desktop: {_desktopArrived}");
- }
- catch (Exception ex)
- {
- _logger.Debug($"[StateSnapshot] Error: {ex.Message}");
- }
- }
-
- ///
- /// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
- /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
- ///
- private void EmitAppTrackingSummaryIfChanged()
- {
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- var hash = GetSummaryHash(states);
- if (hash == _lastEmittedSummaryHash) return;
-
- _lastEmittedSummaryHash = hash;
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "app_tracking_summary",
- Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
- (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
- Data = states.GetSummaryData()
- });
- }
-
- ///
- /// Emits app_tracking_summary unconditionally (for final summary on completion).
- ///
- private void EmitAppTrackingSummary()
- {
- var states = _imeLogTracker?.PackageStates;
- if (states == null || states.CountAll == 0) return;
-
- _lastEmittedSummaryHash = GetSummaryHash(states);
-
- _emitEvent(new EnrollmentEvent
- {
- SessionId = _sessionId,
- TenantId = _tenantId,
- EventType = "app_tracking_summary",
- Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
- Source = "EnrollmentTracker",
- Phase = EnrollmentPhase.Unknown,
- Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
- (states.HasError ? $" ({states.ErrorCount} errors)" : ""),
- Data = states.GetSummaryData()
- });
- }
-
- private static string GetSummaryHash(AppPackageStateList states)
- {
- return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
- $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
- }
-
- public void Dispose()
- {
- Stop();
-
- // Unsubscribe from EspAndHelloTracker events
- if (_espAndHelloTracker != null)
- {
- _espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
- _espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
- _espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
- _espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
- }
-
- _espFailureTimer?.Dispose();
- _deviceOnlyEspTimer?.Dispose();
- _waitingForHelloSafetyTimer?.Dispose();
- _summaryTimer?.Dispose();
- _debugStateTimer?.Dispose();
- _imeLogTracker?.Dispose();
- }
-
- ///
- /// Writes an enrollment complete marker to the state directory.
- /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
- ///
- private void WriteEnrollmentCompleteMarker()
- {
- try
- {
- var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
- Directory.CreateDirectory(stateDirectory);
-
- var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
- File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
-
- _logger.Info($"Enrollment complete marker written: {markerPath}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
- }
- }
-
- ///
- /// Writes a comprehensive final-status.json with all app states, signals, and error summary.
- /// Automatically included in diagnostics uploads for post-mortem analysis.
- ///
- private void WriteFinalStatus(string outcome, string source)
- {
- try
- {
- var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
- Directory.CreateDirectory(stateDirectory);
-
- var appStates = _imeLogTracker?.PackageStates;
-
- var signalTimestamps = new Dictionary();
- if (_stateData.EspFirstSeenUtc.HasValue)
- signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
- if (_stateData.EspFinalExitUtc.HasValue)
- signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
- if (_stateData.DesktopArrivedUtc.HasValue)
- signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
- if (_stateData.HelloResolvedUtc.HasValue)
- signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
- if (_stateData.ImePatternSeenUtc.HasValue)
- signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
-
- // Build per-phase package states: snapshots from completed phases + current phase
- var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
- var packageStatesByPhase = new Dictionary();
-
- // Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
- if (phaseSnapshots != null)
- {
- foreach (var kvp in phaseSnapshots)
- packageStatesByPhase[kvp.Key] = kvp.Value;
- }
-
- // Add current phase (the phase active at completion, e.g. AccountSetup)
- var currentPhaseName = _lastEspPhase ?? "Unknown";
- var currentPhaseList = appStates?.ToFinalStatusList() ?? new List>();
- packageStatesByPhase[currentPhaseName] = currentPhaseList;
-
- // Aggregate app summary across ALL phases (snapshots + current)
- var currentPhaseTotal = appStates?.CountAll ?? 0;
- var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
- var currentPhaseErrors = appStates?.ErrorCount ?? 0;
- var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
- var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
-
- var allPhaseTotals = new Dictionary();
- var totalAppsAllPhases = currentPhaseTotal;
- var completedAppsAllPhases = currentPhaseCompleted;
- var errorCountAllPhases = currentPhaseErrors;
- var deviceErrorsAllPhases = currentPhaseDeviceErrors;
- var userErrorsAllPhases = currentPhaseUserErrors;
-
- allPhaseTotals[currentPhaseName] = currentPhaseTotal;
-
- if (phaseSnapshots != null)
- {
- foreach (var kvp in phaseSnapshots)
- {
- var phaseApps = kvp.Value;
- allPhaseTotals[kvp.Key] = phaseApps.Count;
- totalAppsAllPhases += phaseApps.Count;
- foreach (var app in phaseApps)
- {
- object stateVal;
- var isError = app.TryGetValue("state", out stateVal) &&
- string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
- var isCompleted = app.TryGetValue("state", out stateVal) &&
- (stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
- stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
- if (isCompleted) completedAppsAllPhases++;
- if (isError)
- {
- errorCountAllPhases++;
- object targetVal;
- if (app.TryGetValue("targeted", out targetVal))
- {
- if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
- deviceErrorsAllPhases++;
- else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
- userErrorsAllPhases++;
- }
- }
- }
- }
- }
-
- var status = new Dictionary
- {
- { "timestamp", DateTime.UtcNow.ToString("o") },
- { "outcome", outcome },
- { "completionSource", source },
- { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
- { "enrollmentType", _enrollmentType ?? "unknown" },
- { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
- { "signalsSeen", _stateData.SignalsSeen ?? new List() },
- { "signalTimestamps", signalTimestamps },
- { "appSummary", new Dictionary
- {
- { "totalApps", totalAppsAllPhases },
- { "completedApps", completedAppsAllPhases },
- { "errorCount", errorCountAllPhases },
- { "deviceErrors", deviceErrorsAllPhases },
- { "userErrors", userErrorsAllPhases },
- { "appsByPhase", allPhaseTotals }
- }
- },
- { "packageStatesByPhase", packageStatesByPhase }
- };
-
- var json = System.Text.Json.JsonSerializer.Serialize(status,
- new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
- var path = Path.Combine(stateDirectory, "final-status.json");
- File.WriteAllText(path, json);
- _logger.Info($"Final status written: {path}");
- }
- catch (Exception ex)
- {
- _logger.Warning($"Failed to write final status: {ex.Message}");
- }
- }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs
new file mode 100644
index 00000000..10773482
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs
@@ -0,0 +1,580 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
+{
+ ///
+ /// Partial: Event handlers for pattern matches (app state, scripts, ESP phases),
+ /// telemetry processing, and utility methods.
+ ///
+ public partial class ImeLogTracker
+ {
+ private void HandleDoTelemetry(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Extract app GUID from FileId
+ string fileId = root.TryGetProperty("FileId", out var fileIdProp)
+ ? fileIdProp.GetString() : null;
+
+ if (string.IsNullOrEmpty(fileId))
+ {
+ _logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
+ return;
+ }
+
+ string appId = ExtractAppIdFromDoFileId(fileId);
+ if (string.IsNullOrEmpty(appId))
+ {
+ appId = _packageStates.CurrentPackageId;
+ _logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
+ }
+
+ if (string.IsNullOrEmpty(appId)) return;
+
+ var pkg = _packageStates.GetPackage(appId);
+ if (pkg == null)
+ {
+ _logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
+ return;
+ }
+
+ // Extract all DO fields
+ long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
+ long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+ int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
+ string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
+ long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
+
+ pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
+ bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
+ downloadMode, downloadDuration, bytesFromHttp);
+
+ _logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
+ $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
+ $"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
+
+ OnDoTelemetryReceived?.Invoke(pkg);
+ _stateDirty = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Extracts app GUID from DO FileId string.
+ /// Format: ...intunewin-bin_{appGuid}_{number}
+ /// Falls back to trying the second-to-last GUID-like segment.
+ ///
+ private static string ExtractAppIdFromDoFileId(string fileId)
+ {
+ // Primary: look for "intunewin-bin_" marker
+ const string marker = "intunewin-bin_";
+ var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (idx >= 0)
+ {
+ var afterMarker = fileId.Substring(idx + marker.Length);
+ if (afterMarker.Length >= 36)
+ {
+ var candidate = afterMarker.Substring(0, 36);
+ if (Guid.TryParse(candidate, out var guid))
+ return guid.ToString().ToLowerInvariant();
+ }
+ }
+
+ // Fallback: split by underscore and find GUIDs, take the second-to-last one
+ var segments = fileId.Split('_');
+ for (int i = segments.Length - 2; i >= 0; i--)
+ {
+ if (Guid.TryParse(segments[i], out var guid))
+ return guid.ToString().ToLowerInvariant();
+ }
+
+ return null;
+ }
+
+ // -----------------------------------------------------------------------
+ // PowerShell script tracking handlers
+ // -----------------------------------------------------------------------
+
+ private ScriptExecutionState GetCurrentScript(Dictionary parameters)
+ {
+ var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
+ return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
+ ? _currentRemediationScript
+ : GetCurrentPlatformScript();
+ }
+
+ private ScriptExecutionState GetCurrentPlatformScript()
+ {
+ if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
+ _pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
+ return state;
+ return null;
+ }
+
+ private void HandleScriptStarted(Match match, Dictionary parameters)
+ {
+ var id = match.Groups["id"]?.Value;
+ var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
+ var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
+
+ if (string.IsNullOrEmpty(id))
+ {
+ _logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
+ return;
+ }
+
+ if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
+ {
+ // Flush any pending remediation script (shouldn't happen normally, but defensive)
+ if (_currentRemediationScript != null)
+ {
+ _logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
+ EmitScriptEvent(_currentRemediationScript);
+ }
+
+ _currentRemediationScript = new ScriptExecutionState
+ {
+ PolicyId = id,
+ ScriptType = "remediation",
+ ScriptPart = "detection" // detection runs first; updated if remediation runs
+ };
+ _logger.Debug($"ImeLogTracker: remediation script started: {id}");
+ }
+ else
+ {
+ // Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
+ if (!_pendingPlatformScripts.ContainsKey(id))
+ {
+ _pendingPlatformScripts[id] = new ScriptExecutionState
+ {
+ PolicyId = id,
+ ScriptType = "platform"
+ };
+ }
+ _lastPlatformScriptPolicyId = id;
+ _logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
+ }
+ }
+
+ private void HandleScriptContext(Match match, Dictionary parameters)
+ {
+ var context = match.Groups["context"]?.Value;
+ if (string.IsNullOrEmpty(context)) return;
+
+ var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
+ var script = GetCurrentScript(parameters);
+ if (script != null)
+ {
+ script.RunContext = runContext;
+ _logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
+ }
+ }
+
+ private void HandleScriptExitCode(Match match, Dictionary parameters)
+ {
+ var exitCodeStr = match.Groups["exitCode"]?.Value;
+ if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
+
+ var script = GetCurrentScript(parameters);
+ if (script != null)
+ {
+ script.ExitCode = exitCode;
+ _logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
+ }
+ }
+
+ private void HandleScriptOutput(Match match, Dictionary parameters)
+ {
+ var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
+ var script = GetCurrentScript(parameters);
+ if (script == null) return;
+
+ // PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
+ var output = match.Groups["output"]?.Value;
+ var error = match.Groups["error"]?.Value;
+
+ if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
+ {
+ // Combined pattern (write output done. output = ..., error = ...)
+ script.Stdout = TruncateOutput(output.Trim());
+ script.Stderr = TruncateOutput(error.Trim());
+ }
+ else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
+ {
+ script.Stderr = TruncateOutput(output?.Trim());
+ }
+ else
+ {
+ script.Stdout = TruncateOutput(output?.Trim());
+ }
+
+ _logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
+ }
+
+ private void HandleScriptCompleted(Match match, Dictionary parameters)
+ {
+ var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
+ var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
+
+ if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
+ {
+ if (_currentRemediationScript == null) return;
+
+ var compliance = match.Groups["compliance"]?.Value;
+ var id = match.Groups["id"]?.Value;
+
+ _currentRemediationScript.ComplianceResult = compliance;
+ if (!string.IsNullOrEmpty(part))
+ _currentRemediationScript.ScriptPart = part;
+ if (!string.IsNullOrEmpty(id))
+ _currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
+
+ _logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
+ $"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
+
+ EmitScriptEvent(_currentRemediationScript);
+ _currentRemediationScript = null;
+ }
+ else
+ {
+ // Platform script: final result from IntuneManagementExtension.log
+ var id = match.Groups["id"]?.Value;
+ var result = match.Groups["result"]?.Value;
+
+ if (string.IsNullOrEmpty(id)) return;
+
+ // Merge with pending AgentExecutor data if available
+ if (!_pendingPlatformScripts.TryGetValue(id, out var script))
+ {
+ script = new ScriptExecutionState
+ {
+ PolicyId = id,
+ ScriptType = "platform"
+ };
+ }
+
+ script.Result = result;
+
+ _logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
+
+ EmitScriptEvent(script);
+ _pendingPlatformScripts.Remove(id);
+ if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
+ _lastPlatformScriptPolicyId = null;
+ }
+ }
+
+ private void EmitScriptEvent(ScriptExecutionState script)
+ {
+ if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
+ OnScriptCompleted?.Invoke(script);
+ }
+
+ private static string TruncateOutput(string output)
+ {
+ if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
+ return output;
+ return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
+ }
+
+ // -----------------------------------------------------------------------
+ // IME lifecycle handlers
+ // -----------------------------------------------------------------------
+
+ private void HandleImeStarted()
+ {
+ _logger.Info("ImeLogTracker: IME Agent Started detected");
+
+ // Log any currently active app — it will be re-evaluated by new log entries after IME restart.
+ // We intentionally do NOT mark it as Installed here because IME may retry the app.
+ if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
+ {
+ var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
+ if (currentPkg?.IsActive == true)
+ _logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
+ }
+
+ OnImeStarted?.Invoke();
+ }
+
+ private void HandleImeShutdown()
+ {
+ _logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
+
+ foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
+ {
+ _logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
+ UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
+ }
+ }
+
+ private string _lastEspPhaseDetected;
+
+ private void HandleEspPhaseDetected(string espPhaseString)
+ {
+ // Validate phase and get its ordinal for forward-only enforcement
+ int phaseOrd;
+ if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
+ return; // Not a recognized ESP phase
+
+ bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
+
+ // Forward-only phase progression: reject backward transitions.
+ // During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
+ // which would corrupt tracking if we allowed it to trigger a phase change.
+ if (phaseOrd < _currentPhaseOrder)
+ {
+ _logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
+ return;
+ }
+
+ // If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
+ // move ALL known app IDs into the ignore list so they won't emit events in the new phase.
+ // We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
+ // any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
+ // esptrackstatus etc. that never entered _packageStates are also silenced.
+ if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
+ {
+ if (_lastEspPhaseDetected != null) // Not the first phase detection
+ {
+ var ignoredFromStates = 0;
+ var ignoredFromSeen = 0;
+
+ foreach (var pkg in _packageStates)
+ {
+ if (_packageStates.AddToIgnoreList(pkg.Id))
+ ignoredFromStates++;
+ }
+ foreach (var appId in _seenAppIds)
+ {
+ if (_packageStates.AddToIgnoreList(appId))
+ ignoredFromSeen++;
+ }
+
+ _logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
+ $"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
+ $"(total ignore list: {_packageStates.IgnoreList.Count})");
+
+ // Snapshot package states from the completed phase before clearing
+ if (_packageStates.CountAll > 0)
+ {
+ _phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
+ _logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
+ }
+
+ _packageStates.Clear();
+ _packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
+ _seenAppIds.Clear();
+ _allAppsCompletedFired = false;
+ }
+ _lastEspPhaseDetected = espPhaseString;
+ _currentPhaseOrder = phaseOrd;
+ }
+
+ _logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
+ ActivatePatterns(logPhaseIsCurrentPhase);
+
+ OnEspPhaseChanged?.Invoke(espPhaseString);
+ }
+
+ private void HandleEspTrackStatus(Match match)
+ {
+ var from = match.Groups["from"]?.Value;
+ var to = match.Groups["to"]?.Value;
+ var id = match.Groups["id"]?.Value;
+
+ if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
+
+ var pkg = _packageStates.GetPackage(id);
+ var label = pkg?.Name ?? id;
+
+ if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
+ _packageStates.SetCurrent(id);
+ UpdateStateWithCallback(id, AppInstallationState.Installing);
+ }
+ else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
+ UpdateStateWithCallback(id, AppInstallationState.Installed);
+ }
+ else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
+ UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
+ }
+ }
+
+ private void HandlePoliciesDiscovered(string policiesJson)
+ {
+ try
+ {
+ var policies = System.Text.Json.JsonSerializer.Deserialize>>(policiesJson);
+ if (policies != null)
+ {
+ // Ignore user-targeted packages during device setup (we only track device phase in general)
+ _packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
+ _logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
+ OnPoliciesDiscovered?.Invoke(policiesJson);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
+ }
+ }
+
+ private void HandleCancelStuckAndSetCurrent(string newId)
+ {
+ if (string.IsNullOrEmpty(newId)) return;
+
+ // If current app is stuck in an active state, mark it as skipped
+ var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
+ if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
+ currentPkg.InstallationState == AppInstallationState.InProgress ||
+ currentPkg.InstallationState == AppInstallationState.Downloading))
+ {
+ _logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
+ UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
+ }
+
+ _packageStates.SetCurrent(newId);
+ }
+
+ private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null)
+ {
+ var pkg = _packageStates.GetPackage(id);
+ if (pkg == null) return;
+
+ // Set error context before state change so it's available in ToEventData()
+ if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
+ pkg.SetErrorContext(errorPatternId, errorDetail);
+
+ var oldState = pkg.InstallationState;
+ var changed = _packageStates.UpdateState(id, newState, progressPercent);
+
+ if (changed)
+ {
+ _logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
+ OnAppStateChanged?.Invoke(pkg, oldState, newState);
+
+ // Check if all apps are now completed - only fire once
+ if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
+ {
+ _allAppsCompletedFired = true;
+ _logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
+ OnAllAppsCompleted?.Invoke();
+ }
+ }
+ }
+
+ private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
+ {
+ var pkg = _packageStates.GetPackage(id);
+ if (pkg == null) return;
+
+ var oldState = pkg.InstallationState;
+ var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
+
+ if (changed)
+ {
+ _logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
+ OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
+ }
+ }
+
+ private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
+ {
+ if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
+ return;
+
+ var patterns = new List(_patternsAlways);
+ if (logPhaseIsCurrentPhase)
+ {
+ patterns.AddRange(_patternsCurrentPhase);
+ _lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
+ }
+ else
+ {
+ patterns.AddRange(_patternsOtherPhases);
+ }
+
+ _activePatterns = patterns;
+ _packageStates.SetCurrent(""); // Reset current package
+ _logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
+ }
+
+ private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
+ {
+ if (!SimulationMode || logTimestamp == DateTime.MinValue)
+ return;
+
+ if (_lastLogTimestamp != DateTime.MinValue)
+ {
+ var timeSpan = logTimestamp - _lastLogTimestamp;
+ if (timeSpan.TotalMilliseconds > 0)
+ {
+ var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
+ delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
+ if (delayMs > 0)
+ await Task.Delay(delayMs, token);
+ }
+ }
+ _lastLogTimestamp = logTimestamp;
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ _cts?.Dispose();
+ }
+
+ private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
+ {
+ if (string.IsNullOrEmpty(_matchLogPath)) return;
+ try
+ {
+ var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
+ lock (_matchLogLock)
+ {
+ File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
+ }
+ }
+ catch { }
+ }
+
+ ///
+ /// A compiled regex pattern with its action and parameters
+ ///
+ private class CompiledPattern
+ {
+ public string PatternId { get; set; }
+ public Regex Regex { get; set; }
+ public string Action { get; set; }
+ public Dictionary Parameters { get; set; }
+ }
+ }
+}
+
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs
new file mode 100644
index 00000000..b2126968
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs
@@ -0,0 +1,298 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
+{
+ ///
+ /// Partial: Log file polling and pattern matching logic.
+ ///
+ public partial class ImeLogTracker
+ {
+ private async Task CheckLogFilesAsync(CancellationToken token)
+ {
+ if (!Directory.Exists(_logFolder))
+ return;
+
+ // Get all matching log files, sorted by name (archived files come before current)
+ var files = new List();
+ foreach (var pattern in LogFilePatterns)
+ {
+ try
+ {
+ files.AddRange(Directory.GetFiles(_logFolder, pattern));
+ }
+ catch (DirectoryNotFoundException) { }
+ }
+
+ files.Sort(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var filePath in files)
+ {
+ if (token.IsCancellationRequested) break;
+
+ try
+ {
+ var fileInfo = new FileInfo(filePath);
+ if (!fileInfo.Exists) continue;
+
+ var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
+ if (startPos >= fileInfo.Length) continue;
+
+ using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+ {
+ stream.Seek(startPos, SeekOrigin.Begin);
+
+ using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
+ {
+ string line;
+ while ((line = await reader.ReadLineAsync()) != null)
+ {
+ if (token.IsCancellationRequested) break;
+
+ // Parse CMTrace format to get the message content
+ CmTraceLogEntry entry;
+ string messageToMatch;
+ if (CmTraceLogParser.TryParseLine(line, out entry))
+ {
+ messageToMatch = entry.Message;
+ }
+ else
+ {
+ // Non-CMTrace line - match raw
+ messageToMatch = line;
+ entry = null;
+ }
+
+ if (string.IsNullOrEmpty(messageToMatch)) continue;
+
+ // Simulation mode delay
+ if (SimulationMode && entry != null)
+ {
+ await ApplySimulationDelay(entry.Timestamp, token);
+ }
+
+ // Match against active patterns
+ foreach (var pattern in _activePatterns)
+ {
+ try
+ {
+ var match = pattern.Regex.Match(messageToMatch);
+ if (match.Success)
+ {
+ WriteMatchLog(filePath, line, pattern.PatternId);
+ HandlePatternMatch(pattern, match, messageToMatch);
+ }
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ // Skip this pattern match to prevent ReDoS
+ }
+ }
+ }
+ }
+
+ _positionTracker.SetPosition(filePath, stream.Position);
+ _stateDirty = true;
+ }
+ }
+ catch (FileNotFoundException) { }
+ catch (IOException ex)
+ {
+ _logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}");
+ }
+ }
+ }
+
+ private void HandlePatternMatch(CompiledPattern pattern, Match match, string message)
+ {
+ try
+ {
+ var id = match.Groups["id"]?.Value;
+ var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
+ pattern.Parameters["useCurrentApp"] == "true";
+
+ if (useCurrentApp && string.IsNullOrEmpty(id))
+ id = _packageStates.CurrentPackageId;
+
+ // Track every app ID seen during the current phase for comprehensive ignore on phase change
+ if (!string.IsNullOrEmpty(id))
+ _seenAppIds.Add(id);
+
+ switch (pattern.Action?.ToLower())
+ {
+ case "imestarted":
+ HandleImeStarted();
+ break;
+
+ case "imeshutdown":
+ HandleImeShutdown();
+ break;
+
+ case "imesessionchange":
+ _logger.Debug($"IME session change: {match.Groups["change"]?.Value}");
+ break;
+
+ case "espphasedetected":
+ var phase = match.Groups["espPhase"]?.Value;
+ if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
+ phase = pattern.Parameters["phase"];
+ if (!string.IsNullOrEmpty(phase))
+ HandleEspPhaseDetected(phase);
+ break;
+
+ case "setcurrentapp":
+ if (!string.IsNullOrEmpty(id))
+ _packageStates.SetCurrent(id);
+ break;
+
+ case "imeagentversion":
+ var version = match.Groups["agentVersion"]?.Value;
+ if (!string.IsNullOrEmpty(version))
+ OnImeAgentVersion?.Invoke(version);
+ break;
+
+ case "imeimpersonation":
+ _logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
+ break;
+
+ case "enrollmentcompleted":
+ _logger.Info("ImeLogTracker: User session completed detected");
+ OnUserSessionCompleted?.Invoke();
+ break;
+
+ case "updatestateinstalled":
+ if (!string.IsNullOrEmpty(id))
+ UpdateStateWithCallback(id, AppInstallationState.Installed);
+ break;
+
+ case "updatestatedownloading":
+ if (!string.IsNullOrEmpty(id))
+ {
+ var bytes = match.Groups["bytes"]?.Value;
+ var ofbytes = match.Groups["ofbytes"]?.Value;
+ if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
+ UpdateDownloadingWithCallback(id, bytes, ofbytes);
+ else
+ UpdateStateWithCallback(id, AppInstallationState.Downloading);
+ }
+ break;
+
+ case "updatestateinstalling":
+ if (!string.IsNullOrEmpty(id))
+ UpdateStateWithCallback(id, AppInstallationState.Installing);
+ break;
+
+ case "updatestateskipped":
+ if (!string.IsNullOrEmpty(id))
+ UpdateStateWithCallback(id, AppInstallationState.Skipped);
+ break;
+
+ case "updatestateerror":
+ if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
+ {
+ // Only apply error if the "to" value is "Error"
+ var toValue = match.Groups["to"]?.Value;
+ if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
+ UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
+ }
+ else if (!string.IsNullOrEmpty(id))
+ {
+ UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
+ }
+ break;
+
+ case "updatestatepostponed":
+ if (!string.IsNullOrEmpty(id))
+ {
+ // Only postpone if not already in a terminal state
+ var pkg = _packageStates.GetPackage(id);
+ if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
+ pkg.InstallationState != AppInstallationState.Error)
+ {
+ UpdateStateWithCallback(id, AppInstallationState.Postponed);
+ }
+ }
+ break;
+
+ case "esptrackstatus":
+ HandleEspTrackStatus(match);
+ break;
+
+ case "policiesdiscovered":
+ var policiesJson = match.Groups["policies"]?.Value;
+ if (!string.IsNullOrEmpty(policiesJson))
+ HandlePoliciesDiscovered(policiesJson);
+ break;
+
+ case "ignorecompletedapp":
+ _packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
+ break;
+
+ case "updatename":
+ var name = match.Groups["name"]?.Value;
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
+ _packageStates.UpdateName(id, name);
+ break;
+
+ case "updatewin32appstate":
+ var state = match.Groups["state"]?.Value;
+ if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
+ _packageStates.UpdateStateFromWin32AppState(id, state);
+ break;
+
+ case "cancelstuckandsetcurrent":
+ HandleCancelStuckAndSetCurrent(id);
+ break;
+
+ case "updatedotelemetry":
+ var doTelJson = match.Groups["doTelJson"]?.Value;
+ if (!string.IsNullOrEmpty(doTelJson))
+ HandleDoTelemetry(doTelJson);
+ break;
+
+ // PowerShell script tracking actions
+ case "scriptstarted":
+ HandleScriptStarted(match, pattern.Parameters);
+ break;
+
+ case "scriptcontext":
+ HandleScriptContext(match, pattern.Parameters);
+ break;
+
+ case "scriptexitcode":
+ HandleScriptExitCode(match, pattern.Parameters);
+ break;
+
+ case "scriptoutput":
+ HandleScriptOutput(match, pattern.Parameters);
+ break;
+
+ case "scriptcompleted":
+ HandleScriptCompleted(match, pattern.Parameters);
+ break;
+
+ default:
+ _logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Parses [DO TEL] JSON and links it to the correct app via FileId.
+ /// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
+ ///
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs
index eddd3d35..825b7e45 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs
@@ -18,12 +18,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
///
/// Parses IME (Intune Management Extension) log files using regex patterns from the backend.
/// Tracks app installation state transitions and emits strategic events.
- /// Adapted from EspOverlay's ImeLogFileParser + RjLogFileParser.
+ /// Split into partial classes: Core, LogProcessing, Handlers.
///
/// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list.
/// This allows updating patterns without agent rebuild when Microsoft changes IME log output.
///
- public class ImeLogTracker : IDisposable
+ public partial class ImeLogTracker : IDisposable
{
private readonly string _logFolder;
private readonly AgentLogger _logger;
@@ -414,840 +414,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
#endregion
- private async Task CheckLogFilesAsync(CancellationToken token)
- {
- if (!Directory.Exists(_logFolder))
- return;
-
- // Get all matching log files, sorted by name (archived files come before current)
- var files = new List();
- foreach (var pattern in LogFilePatterns)
- {
- try
- {
- files.AddRange(Directory.GetFiles(_logFolder, pattern));
- }
- catch (DirectoryNotFoundException) { }
- }
-
- files.Sort(StringComparer.OrdinalIgnoreCase);
-
- foreach (var filePath in files)
- {
- if (token.IsCancellationRequested) break;
-
- try
- {
- var fileInfo = new FileInfo(filePath);
- if (!fileInfo.Exists) continue;
-
- var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
- if (startPos >= fileInfo.Length) continue;
-
- using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
- {
- stream.Seek(startPos, SeekOrigin.Begin);
-
- using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
- {
- string line;
- while ((line = await reader.ReadLineAsync()) != null)
- {
- if (token.IsCancellationRequested) break;
-
- // Parse CMTrace format to get the message content
- CmTraceLogEntry entry;
- string messageToMatch;
- if (CmTraceLogParser.TryParseLine(line, out entry))
- {
- messageToMatch = entry.Message;
- }
- else
- {
- // Non-CMTrace line - match raw
- messageToMatch = line;
- entry = null;
- }
-
- if (string.IsNullOrEmpty(messageToMatch)) continue;
-
- // Simulation mode delay
- if (SimulationMode && entry != null)
- {
- await ApplySimulationDelay(entry.Timestamp, token);
- }
-
- // Match against active patterns
- foreach (var pattern in _activePatterns)
- {
- try
- {
- var match = pattern.Regex.Match(messageToMatch);
- if (match.Success)
- {
- WriteMatchLog(filePath, line, pattern.PatternId);
- HandlePatternMatch(pattern, match, messageToMatch);
- }
- }
- catch (RegexMatchTimeoutException)
- {
- // Skip this pattern match to prevent ReDoS
- }
- }
- }
- }
-
- _positionTracker.SetPosition(filePath, stream.Position);
- _stateDirty = true;
- }
- }
- catch (FileNotFoundException) { }
- catch (IOException ex)
- {
- _logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}");
- }
- }
- }
-
- private void HandlePatternMatch(CompiledPattern pattern, Match match, string message)
- {
- try
- {
- var id = match.Groups["id"]?.Value;
- var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
- pattern.Parameters["useCurrentApp"] == "true";
-
- if (useCurrentApp && string.IsNullOrEmpty(id))
- id = _packageStates.CurrentPackageId;
-
- // Track every app ID seen during the current phase for comprehensive ignore on phase change
- if (!string.IsNullOrEmpty(id))
- _seenAppIds.Add(id);
-
- switch (pattern.Action?.ToLower())
- {
- case "imestarted":
- HandleImeStarted();
- break;
-
- case "imeshutdown":
- HandleImeShutdown();
- break;
-
- case "imesessionchange":
- _logger.Debug($"IME session change: {match.Groups["change"]?.Value}");
- break;
-
- case "espphasedetected":
- var phase = match.Groups["espPhase"]?.Value;
- if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
- phase = pattern.Parameters["phase"];
- if (!string.IsNullOrEmpty(phase))
- HandleEspPhaseDetected(phase);
- break;
-
- case "setcurrentapp":
- if (!string.IsNullOrEmpty(id))
- _packageStates.SetCurrent(id);
- break;
-
- case "imeagentversion":
- var version = match.Groups["agentVersion"]?.Value;
- if (!string.IsNullOrEmpty(version))
- OnImeAgentVersion?.Invoke(version);
- break;
-
- case "imeimpersonation":
- _logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
- break;
-
- case "enrollmentcompleted":
- _logger.Info("ImeLogTracker: User session completed detected");
- OnUserSessionCompleted?.Invoke();
- break;
-
- case "updatestateinstalled":
- if (!string.IsNullOrEmpty(id))
- UpdateStateWithCallback(id, AppInstallationState.Installed);
- break;
-
- case "updatestatedownloading":
- if (!string.IsNullOrEmpty(id))
- {
- var bytes = match.Groups["bytes"]?.Value;
- var ofbytes = match.Groups["ofbytes"]?.Value;
- if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
- UpdateDownloadingWithCallback(id, bytes, ofbytes);
- else
- UpdateStateWithCallback(id, AppInstallationState.Downloading);
- }
- break;
-
- case "updatestateinstalling":
- if (!string.IsNullOrEmpty(id))
- UpdateStateWithCallback(id, AppInstallationState.Installing);
- break;
-
- case "updatestateskipped":
- if (!string.IsNullOrEmpty(id))
- UpdateStateWithCallback(id, AppInstallationState.Skipped);
- break;
-
- case "updatestateerror":
- if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
- {
- // Only apply error if the "to" value is "Error"
- var toValue = match.Groups["to"]?.Value;
- if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
- UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
- }
- else if (!string.IsNullOrEmpty(id))
- {
- UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message);
- }
- break;
-
- case "updatestatepostponed":
- if (!string.IsNullOrEmpty(id))
- {
- // Only postpone if not already in a terminal state
- var pkg = _packageStates.GetPackage(id);
- if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
- pkg.InstallationState != AppInstallationState.Error)
- {
- UpdateStateWithCallback(id, AppInstallationState.Postponed);
- }
- }
- break;
-
- case "esptrackstatus":
- HandleEspTrackStatus(match);
- break;
-
- case "policiesdiscovered":
- var policiesJson = match.Groups["policies"]?.Value;
- if (!string.IsNullOrEmpty(policiesJson))
- HandlePoliciesDiscovered(policiesJson);
- break;
-
- case "ignorecompletedapp":
- _packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
- break;
-
- case "updatename":
- var name = match.Groups["name"]?.Value;
- if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
- _packageStates.UpdateName(id, name);
- break;
-
- case "updatewin32appstate":
- var state = match.Groups["state"]?.Value;
- if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
- _packageStates.UpdateStateFromWin32AppState(id, state);
- break;
-
- case "cancelstuckandsetcurrent":
- HandleCancelStuckAndSetCurrent(id);
- break;
-
- case "updatedotelemetry":
- var doTelJson = match.Groups["doTelJson"]?.Value;
- if (!string.IsNullOrEmpty(doTelJson))
- HandleDoTelemetry(doTelJson);
- break;
-
- // PowerShell script tracking actions
- case "scriptstarted":
- HandleScriptStarted(match, pattern.Parameters);
- break;
-
- case "scriptcontext":
- HandleScriptContext(match, pattern.Parameters);
- break;
-
- case "scriptexitcode":
- HandleScriptExitCode(match, pattern.Parameters);
- break;
-
- case "scriptoutput":
- HandleScriptOutput(match, pattern.Parameters);
- break;
-
- case "scriptcompleted":
- HandleScriptCompleted(match, pattern.Parameters);
- break;
-
- default:
- _logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
- break;
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
- }
- }
-
- ///
- /// Parses [DO TEL] JSON and links it to the correct app via FileId.
- /// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
- ///
- private void HandleDoTelemetry(string json)
- {
- try
- {
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
-
- // Extract app GUID from FileId
- string fileId = root.TryGetProperty("FileId", out var fileIdProp)
- ? fileIdProp.GetString() : null;
-
- if (string.IsNullOrEmpty(fileId))
- {
- _logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
- return;
- }
-
- string appId = ExtractAppIdFromDoFileId(fileId);
- if (string.IsNullOrEmpty(appId))
- {
- appId = _packageStates.CurrentPackageId;
- _logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
- }
-
- if (string.IsNullOrEmpty(appId)) return;
-
- var pkg = _packageStates.GetPackage(appId);
- if (pkg == null)
- {
- _logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
- return;
- }
-
- // Extract all DO fields
- long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
- long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
- int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
- string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
- long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
-
- pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
- bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
- downloadMode, downloadDuration, bytesFromHttp);
-
- _logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
- $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
- $"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
-
- OnDoTelemetryReceived?.Invoke(pkg);
- _stateDirty = true;
- }
- catch (Exception ex)
- {
- _logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
- }
- }
-
- ///
- /// Extracts app GUID from DO FileId string.
- /// Format: ...intunewin-bin_{appGuid}_{number}
- /// Falls back to trying the second-to-last GUID-like segment.
- ///
- private static string ExtractAppIdFromDoFileId(string fileId)
- {
- // Primary: look for "intunewin-bin_" marker
- const string marker = "intunewin-bin_";
- var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
- if (idx >= 0)
- {
- var afterMarker = fileId.Substring(idx + marker.Length);
- if (afterMarker.Length >= 36)
- {
- var candidate = afterMarker.Substring(0, 36);
- if (Guid.TryParse(candidate, out var guid))
- return guid.ToString().ToLowerInvariant();
- }
- }
-
- // Fallback: split by underscore and find GUIDs, take the second-to-last one
- var segments = fileId.Split('_');
- for (int i = segments.Length - 2; i >= 0; i--)
- {
- if (Guid.TryParse(segments[i], out var guid))
- return guid.ToString().ToLowerInvariant();
- }
-
- return null;
- }
-
- // -----------------------------------------------------------------------
- // PowerShell script tracking handlers
- // -----------------------------------------------------------------------
-
- private ScriptExecutionState GetCurrentScript(Dictionary parameters)
- {
- var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
- return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
- ? _currentRemediationScript
- : GetCurrentPlatformScript();
- }
-
- private ScriptExecutionState GetCurrentPlatformScript()
- {
- if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
- _pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
- return state;
- return null;
- }
-
- private void HandleScriptStarted(Match match, Dictionary parameters)
- {
- var id = match.Groups["id"]?.Value;
- var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
- var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
-
- if (string.IsNullOrEmpty(id))
- {
- _logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
- return;
- }
-
- if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
- {
- // Flush any pending remediation script (shouldn't happen normally, but defensive)
- if (_currentRemediationScript != null)
- {
- _logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
- EmitScriptEvent(_currentRemediationScript);
- }
-
- _currentRemediationScript = new ScriptExecutionState
- {
- PolicyId = id,
- ScriptType = "remediation",
- ScriptPart = "detection" // detection runs first; updated if remediation runs
- };
- _logger.Debug($"ImeLogTracker: remediation script started: {id}");
- }
- else
- {
- // Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
- if (!_pendingPlatformScripts.ContainsKey(id))
- {
- _pendingPlatformScripts[id] = new ScriptExecutionState
- {
- PolicyId = id,
- ScriptType = "platform"
- };
- }
- _lastPlatformScriptPolicyId = id;
- _logger.Debug($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
- }
- }
-
- private void HandleScriptContext(Match match, Dictionary parameters)
- {
- var context = match.Groups["context"]?.Value;
- if (string.IsNullOrEmpty(context)) return;
-
- var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
- var script = GetCurrentScript(parameters);
- if (script != null)
- {
- script.RunContext = runContext;
- _logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
- }
- }
-
- private void HandleScriptExitCode(Match match, Dictionary parameters)
- {
- var exitCodeStr = match.Groups["exitCode"]?.Value;
- if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
-
- var script = GetCurrentScript(parameters);
- if (script != null)
- {
- script.ExitCode = exitCode;
- _logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
- }
- }
-
- private void HandleScriptOutput(Match match, Dictionary parameters)
- {
- var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
- var script = GetCurrentScript(parameters);
- if (script == null) return;
-
- // PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
- var output = match.Groups["output"]?.Value;
- var error = match.Groups["error"]?.Value;
-
- if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
- {
- // Combined pattern (write output done. output = ..., error = ...)
- script.Stdout = TruncateOutput(output.Trim());
- script.Stderr = TruncateOutput(error.Trim());
- }
- else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
- {
- script.Stderr = TruncateOutput(output?.Trim());
- }
- else
- {
- script.Stdout = TruncateOutput(output?.Trim());
- }
-
- _logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
- }
-
- private void HandleScriptCompleted(Match match, Dictionary parameters)
- {
- var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
- var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
-
- if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
- {
- if (_currentRemediationScript == null) return;
-
- var compliance = match.Groups["compliance"]?.Value;
- var id = match.Groups["id"]?.Value;
-
- _currentRemediationScript.ComplianceResult = compliance;
- if (!string.IsNullOrEmpty(part))
- _currentRemediationScript.ScriptPart = part;
- if (!string.IsNullOrEmpty(id))
- _currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
-
- _logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
- $"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
-
- EmitScriptEvent(_currentRemediationScript);
- _currentRemediationScript = null;
- }
- else
- {
- // Platform script: final result from IntuneManagementExtension.log
- var id = match.Groups["id"]?.Value;
- var result = match.Groups["result"]?.Value;
-
- if (string.IsNullOrEmpty(id)) return;
-
- // Merge with pending AgentExecutor data if available
- if (!_pendingPlatformScripts.TryGetValue(id, out var script))
- {
- script = new ScriptExecutionState
- {
- PolicyId = id,
- ScriptType = "platform"
- };
- }
-
- script.Result = result;
-
- _logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
-
- EmitScriptEvent(script);
- _pendingPlatformScripts.Remove(id);
- if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
- _lastPlatformScriptPolicyId = null;
- }
- }
-
- private void EmitScriptEvent(ScriptExecutionState script)
- {
- if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
- OnScriptCompleted?.Invoke(script);
- }
-
- private static string TruncateOutput(string output)
- {
- if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
- return output;
- return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
- }
-
- // -----------------------------------------------------------------------
- // IME lifecycle handlers
- // -----------------------------------------------------------------------
-
- private void HandleImeStarted()
- {
- _logger.Info("ImeLogTracker: IME Agent Started detected");
-
- // Log any currently active app — it will be re-evaluated by new log entries after IME restart.
- // We intentionally do NOT mark it as Installed here because IME may retry the app.
- if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
- {
- var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
- if (currentPkg?.IsActive == true)
- _logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
- }
-
- OnImeStarted?.Invoke();
- }
-
- private void HandleImeShutdown()
- {
- _logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
-
- foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
- {
- _logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
- UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
- }
- }
-
- private string _lastEspPhaseDetected;
-
- private void HandleEspPhaseDetected(string espPhaseString)
- {
- // Validate phase and get its ordinal for forward-only enforcement
- int phaseOrd;
- if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
- return; // Not a recognized ESP phase
-
- bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
-
- // Forward-only phase progression: reject backward transitions.
- // During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
- // which would corrupt tracking if we allowed it to trigger a phase change.
- if (phaseOrd < _currentPhaseOrder)
- {
- _logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
- return;
- }
-
- // If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
- // move ALL known app IDs into the ignore list so they won't emit events in the new phase.
- // We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
- // any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
- // esptrackstatus etc. that never entered _packageStates are also silenced.
- if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
- {
- if (_lastEspPhaseDetected != null) // Not the first phase detection
- {
- var ignoredFromStates = 0;
- var ignoredFromSeen = 0;
-
- foreach (var pkg in _packageStates)
- {
- if (_packageStates.AddToIgnoreList(pkg.Id))
- ignoredFromStates++;
- }
- foreach (var appId in _seenAppIds)
- {
- if (_packageStates.AddToIgnoreList(appId))
- ignoredFromSeen++;
- }
-
- _logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
- $"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
- $"(total ignore list: {_packageStates.IgnoreList.Count})");
-
- // Snapshot package states from the completed phase before clearing
- if (_packageStates.CountAll > 0)
- {
- _phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
- _logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
- }
-
- _packageStates.Clear();
- _packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
- _seenAppIds.Clear();
- _allAppsCompletedFired = false;
- }
- _lastEspPhaseDetected = espPhaseString;
- _currentPhaseOrder = phaseOrd;
- }
-
- _logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
- ActivatePatterns(logPhaseIsCurrentPhase);
-
- OnEspPhaseChanged?.Invoke(espPhaseString);
- }
-
- private void HandleEspTrackStatus(Match match)
- {
- var from = match.Groups["from"]?.Value;
- var to = match.Groups["to"]?.Value;
- var id = match.Groups["id"]?.Value;
-
- if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
-
- var pkg = _packageStates.GetPackage(id);
- var label = pkg?.Name ?? id;
-
- if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
- _packageStates.SetCurrent(id);
- UpdateStateWithCallback(id, AppInstallationState.Installing);
- }
- else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
- UpdateStateWithCallback(id, AppInstallationState.Installed);
- }
- else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
- {
- _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
- UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
- }
- }
-
- private void HandlePoliciesDiscovered(string policiesJson)
- {
- try
- {
- var policies = System.Text.Json.JsonSerializer.Deserialize>>(policiesJson);
- if (policies != null)
- {
- // Ignore user-targeted packages during device setup (we only track device phase in general)
- _packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
- _logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
- OnPoliciesDiscovered?.Invoke(policiesJson);
- }
- }
- catch (Exception ex)
- {
- _logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
- }
- }
-
- private void HandleCancelStuckAndSetCurrent(string newId)
- {
- if (string.IsNullOrEmpty(newId)) return;
-
- // If current app is stuck in an active state, mark it as skipped
- var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
- if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
- currentPkg.InstallationState == AppInstallationState.InProgress ||
- currentPkg.InstallationState == AppInstallationState.Downloading))
- {
- _logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
- UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
- }
-
- _packageStates.SetCurrent(newId);
- }
-
- private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null)
- {
- var pkg = _packageStates.GetPackage(id);
- if (pkg == null) return;
-
- // Set error context before state change so it's available in ToEventData()
- if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
- pkg.SetErrorContext(errorPatternId, errorDetail);
-
- var oldState = pkg.InstallationState;
- var changed = _packageStates.UpdateState(id, newState, progressPercent);
-
- if (changed)
- {
- _logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
- OnAppStateChanged?.Invoke(pkg, oldState, newState);
-
- // Check if all apps are now completed - only fire once
- if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
- {
- _allAppsCompletedFired = true;
- _logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
- OnAllAppsCompleted?.Invoke();
- }
- }
- }
-
- private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
- {
- var pkg = _packageStates.GetPackage(id);
- if (pkg == null) return;
-
- var oldState = pkg.InstallationState;
- var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
-
- if (changed)
- {
- _logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
- OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
- }
- }
-
- private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
- {
- if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
- return;
-
- var patterns = new List(_patternsAlways);
- if (logPhaseIsCurrentPhase)
- {
- patterns.AddRange(_patternsCurrentPhase);
- _lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
- }
- else
- {
- patterns.AddRange(_patternsOtherPhases);
- }
-
- _activePatterns = patterns;
- _packageStates.SetCurrent(""); // Reset current package
- _logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
- }
-
- private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
- {
- if (!SimulationMode || logTimestamp == DateTime.MinValue)
- return;
-
- if (_lastLogTimestamp != DateTime.MinValue)
- {
- var timeSpan = logTimestamp - _lastLogTimestamp;
- if (timeSpan.TotalMilliseconds > 0)
- {
- var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
- delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
- if (delayMs > 0)
- await Task.Delay(delayMs, token);
- }
- }
- _lastLogTimestamp = logTimestamp;
- }
-
- public void Dispose()
- {
- Stop();
- _cts?.Dispose();
- }
-
- private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
- {
- if (string.IsNullOrEmpty(_matchLogPath)) return;
- try
- {
- var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
- lock (_matchLogLock)
- {
- File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
- }
- }
- catch { }
- }
-
- ///
- /// A compiled regex pattern with its action and parameters
- ///
- private class CompiledPattern
- {
- public string PatternId { get; set; }
- public Regex Regex { get; set; }
- public string Action { get; set; }
- public Dictionary Parameters { get; set; }
- }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs
new file mode 100644
index 00000000..2d76fce8
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs
@@ -0,0 +1,368 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Monitoring.Core;
+using AutopilotMonitor.Shared;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent
+{
+ partial class Program
+ {
+ static string GetTenantIdFromRegistry()
+ {
+ try
+ {
+ const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
+
+ using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
+ {
+ if (enrollmentsKey == null)
+ return null;
+
+ foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
+ {
+ using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
+ {
+ if (enrollmentKey == null) continue;
+
+ var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
+ if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6)
+ {
+ var tenantId = enrollmentKey.GetValue("AADTenantID");
+ if (tenantId != null)
+ return tenantId.ToString();
+ }
+ }
+ }
+ }
+ }
+ catch { }
+
+ return null;
+ }
+
+ static AgentConfiguration LoadConfiguration(string[] args = null)
+ {
+ var noAuth = args?.Contains("--no-auth") ?? false;
+ var noCleanup = args?.Contains("--no-cleanup") ?? false;
+ var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
+ var disableGeoLoc = args?.Contains("--disable-geolocation") ?? false;
+ var newSession = args?.Contains("--new-session") ?? false;
+ var keepLogFile = args?.Contains("--keep-logfile") ?? false;
+
+ string certThumbprint = null;
+ string tenantIdOverride = null;
+ string apiBaseUrlOverride = null;
+ string imeLogPathOverride = null;
+ string imeMatchLogPath = null;
+ string replayLogDir = null;
+ string bootstrapToken = null;
+ double replaySpeedFactor = 50;
+
+ if (args != null)
+ {
+ var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
+ if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length)
+ certThumbprint = args[thumbprintIndex + 1];
+
+ var tenantIdIndex = Array.IndexOf(args, "--tenant-id");
+ if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length)
+ tenantIdOverride = args[tenantIdIndex + 1];
+
+ // Local backend URL override for debugging/testing.
+ // Supported aliases:
+ // --api-url https://...
+ // --backend-api https://...
+ apiBaseUrlOverride = GetArgValue(args, "--api-url", "--backend-api");
+
+ var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path");
+ if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length)
+ imeLogPathOverride = args[imeLogPathIndex + 1];
+
+ var imeMatchLogIndex = Array.IndexOf(args, "--ime-match-log");
+ if (imeMatchLogIndex >= 0 && imeMatchLogIndex + 1 < args.Length)
+ imeMatchLogPath = args[imeMatchLogIndex + 1];
+
+ var replayLogDirIndex = Array.IndexOf(args, "--replay-log-dir");
+ if (replayLogDirIndex >= 0 && replayLogDirIndex + 1 < args.Length)
+ replayLogDir = args[replayLogDirIndex + 1];
+
+ var replaySpeedIndex = Array.IndexOf(args, "--replay-speed-factor");
+ if (replaySpeedIndex >= 0 && replaySpeedIndex + 1 < args.Length)
+ if (double.TryParse(args[replaySpeedIndex + 1], out var speed))
+ replaySpeedFactor = speed;
+
+ var bootstrapTokenIndex = Array.IndexOf(args, "--bootstrap-token");
+ if (bootstrapTokenIndex >= 0 && bootstrapTokenIndex + 1 < args.Length)
+ bootstrapToken = args[bootstrapTokenIndex + 1];
+ }
+
+ // Defaults
+ string apiBaseUrl = Constants.ApiBaseUrl;
+ int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds;
+ bool selfDestructOnComplete = false;
+ bool rebootOnCompleteConfig = false;
+ bool enableGeoLocationConfig = true;
+ bool useClientCertAuthConfig = true;
+
+ // Environment variable overrides built-in default
+ apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl;
+ // CLI override wins over environment variable/default
+ if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride))
+ apiBaseUrl = apiBaseUrlOverride;
+
+ if (noCleanup)
+ {
+ selfDestructOnComplete = false;
+ }
+
+ // Determine TenantId: command-line arg > registry > fallback
+ string tenantId;
+ if (!string.IsNullOrEmpty(tenantIdOverride))
+ {
+ tenantId = tenantIdOverride;
+ }
+ else
+ {
+ tenantId = GetTenantIdFromRegistry();
+ }
+
+ var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
+ var sessionPersist = new SessionPersistence(dataDirectory);
+
+ if (newSession)
+ sessionPersist.DeleteSession();
+
+ var sessionId = sessionPersist.LoadOrCreateSessionId();
+
+ // Bootstrap config: if no --bootstrap-token CLI arg, check for persisted config file.
+ // The OOBE bootstrap script calls --install --bootstrap-token {TOKEN} --tenant-id {TENANTID}.
+ // RunInstallMode persists these to bootstrap-config.json so the Scheduled Task picks them up
+ // on restart (the task command line has no args — just the exe path).
+ var bootstrapConfigPath = Path.Combine(dataDirectory, "bootstrap-config.json");
+ if (string.IsNullOrEmpty(bootstrapToken) && File.Exists(bootstrapConfigPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(bootstrapConfigPath);
+ var bootstrapConfig = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
+ if (bootstrapConfig != null)
+ {
+ bootstrapToken = bootstrapConfig.BootstrapToken;
+ if (string.IsNullOrEmpty(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId))
+ {
+ tenantId = bootstrapConfig.TenantId;
+ }
+ // Also set noAuth so cert search is skipped
+ noAuth = true;
+ }
+ }
+ catch { /* ignore corrupt file */ }
+ }
+
+ var useBootstrapToken = !string.IsNullOrEmpty(bootstrapToken);
+
+ return new AgentConfiguration
+ {
+ ApiBaseUrl = apiBaseUrl,
+ SessionId = sessionId,
+ TenantId = tenantId,
+ SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
+ LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
+ UploadIntervalSeconds = uploadIntervalSeconds,
+ MaxBatchSize = Constants.MaxBatchSize,
+ LogLevel = AgentLogLevel.Info,
+ UseClientCertAuth = !noAuth && !useBootstrapToken && useClientCertAuthConfig,
+ ClientCertThumbprint = certThumbprint,
+ SelfDestructOnComplete = selfDestructOnComplete,
+ RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
+ EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig,
+ ImeLogPathOverride = imeLogPathOverride,
+ ImeMatchLogPath = imeMatchLogPath,
+ ReplayLogDir = replayLogDir,
+ ReplaySpeedFactor = replaySpeedFactor,
+ KeepLogFile = keepLogFile,
+ BootstrapToken = bootstrapToken,
+ UseBootstrapTokenAuth = useBootstrapToken
+ };
+ }
+
+ private static string GetArgValue(string[] args, params string[] names)
+ {
+ if (args == null || names == null || names.Length == 0)
+ return null;
+
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ foreach (var name in names)
+ {
+ if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
+ {
+ return args[i + 1];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Checks for enrollment complete marker and handles cleanup retry if needed.
+ /// Returns true if marker was found and agent should exit.
+ ///
+ static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode)
+ {
+ var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
+ var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
+
+ if (!File.Exists(markerPath))
+ {
+ // No marker - proceed with normal enrollment
+ return false;
+ }
+
+ logger.Info("Enrollment complete marker detected from previous session");
+
+ if (!config.SelfDestructOnComplete)
+ {
+ // No cleanup configured - just exit
+ logger.Info("Enrollment already completed (SelfDestructOnComplete is disabled). Agent will exit.");
+ if (consoleMode)
+ Console.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit.");
+ return true;
+ }
+
+ // Self-destruct is configured - attempt it now in case scheduled task failed
+ logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)...");
+ if (consoleMode)
+ Console.WriteLine("Enrollment already completed. Attempting cleanup retry...");
+
+ try
+ {
+ using (var service = new MonitoringService(config, logger, GetAgentVersion()))
+ {
+ service.TriggerCleanup();
+ }
+
+ logger.Info("Cleanup retry completed. Agent will exit.");
+ if (consoleMode)
+ Console.WriteLine("Cleanup retry completed. Agent will exit.");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"Cleanup retry failed: {ex.Message}");
+ if (consoleMode)
+ Console.WriteLine($"WARNING: Cleanup retry failed: {ex.Message}");
+ }
+
+ return true;
+ }
+
+ ///
+ /// Emergency break: checks if the current session has been alive longer than the absolute maximum.
+ /// Prevents zombie agents that stay on the device forever due to logic errors preventing
+ /// normal enrollment completion. Respects WhiteGlove scenarios (skips check during resume).
+ /// Returns true if emergency break triggered and agent should exit.
+ ///
+ static bool CheckSessionAgeEmergencyBreak(AgentConfiguration config, AgentLogger logger, bool consoleMode)
+ {
+ try
+ {
+ var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
+ var persistence = new SessionPersistence(dataDir);
+
+ // Skip check if WhiteGlove-paused (device powered off between Part 1 and Part 2).
+ // The timer will be reset when MonitoringService.Start() processes the WhiteGlove resume.
+ if (persistence.IsWhiteGloveResume())
+ {
+ logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check");
+ return false;
+ }
+
+ var sessionCreatedAt = persistence.LoadSessionCreatedAt();
+ if (sessionCreatedAt == null)
+ {
+ // Older agent without session.created — initialize the file now.
+ // The emergency break will only trigger on subsequent restarts.
+ if (persistence.SessionExists())
+ {
+ persistence.SaveSessionCreatedAt(DateTime.UtcNow);
+ logger.Info("Emergency break: Initialized session.created for existing session");
+ }
+ return false;
+ }
+
+ var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours;
+ var maxAgeHours = config.AbsoluteMaxSessionHours;
+
+ if (sessionAgeHours <= maxAgeHours)
+ {
+ logger.Info($"Emergency break: Session age {sessionAgeHours:F1}h within limit ({maxAgeHours}h)");
+ return false;
+ }
+
+ // Session too old — emergency self-destruct
+ logger.Warning($"EMERGENCY BREAK: Session age {sessionAgeHours:F1}h exceeds maximum {maxAgeHours}h — forcing cleanup");
+
+ if (consoleMode)
+ Console.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {maxAgeHours}h). Forcing cleanup.");
+
+ // Write enrollment-complete marker so next start exits cleanly
+ var stateDir = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
+ Directory.CreateDirectory(stateDir);
+ var markerPath = Path.Combine(stateDir, "enrollment-complete.marker");
+ File.WriteAllText(markerPath, $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)");
+
+ if (config.SelfDestructOnComplete)
+ {
+ using (var service = new MonitoringService(config, logger, GetAgentVersion()))
+ {
+ service.TriggerCleanup();
+ }
+ }
+
+ // Clean up session files
+ persistence.DeleteSession();
+
+ logger.Info("Emergency break: Cleanup completed. Agent will exit.");
+ return true;
+ }
+ catch (Exception ex)
+ {
+ logger.Error($"Emergency break check failed: {ex.Message}", ex);
+ return false; // Don't block startup on check failure
+ }
+ }
+
+ static string GetAgentVersion()
+ {
+ try
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var informationalVersion = assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ if (!string.IsNullOrWhiteSpace(informationalVersion))
+ return informationalVersion;
+
+ var fileVersion = assembly
+ .GetCustomAttribute()
+ ?.Version;
+
+ if (!string.IsNullOrWhiteSpace(fileVersion))
+ return fileVersion;
+
+ return assembly.GetName().Version?.ToString() ?? "unknown";
+ }
+ catch
+ {
+ return "unknown";
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs
new file mode 100644
index 00000000..fd6efa25
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
+using AutopilotMonitor.Agent.Core.Monitoring.Core;
+using AutopilotMonitor.Agent.Core.Monitoring.Network;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent
+{
+ partial class Program
+ {
+ ///
+ /// Fetches the latest remote config, executes all startup gather rules against the
+ /// current session (or a freshly registered one if no session.id exists), uploads the
+ /// collected events, then exits. Intended for manual testing and debugging.
+ ///
+ static void RunGatherRulesMode(string[] args)
+ {
+ var consoleMode = args.Contains("--console") || Environment.UserInteractive;
+
+ if (consoleMode)
+ {
+ Console.WriteLine("Autopilot Monitor Agent — Run Gather Rules");
+ Console.WriteLine("==========================================");
+ Console.WriteLine();
+ }
+
+ try
+ {
+ var config = LoadConfiguration(args);
+ var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
+ var logger = new AgentLogger(logDir, config.LogLevel);
+ var agentVersion = GetAgentVersion();
+
+ // Always use a fresh session so gather-rules runs appear as a
+ // distinct portal entry and never pollute an active enrollment session.
+ config.SessionId = Guid.NewGuid().ToString();
+
+ logger.Info("======================= --run-gather-rules mode =======================");
+
+ if (consoleMode)
+ {
+ Console.WriteLine($"Session ID: {config.SessionId} (new, ephemeral)");
+ Console.WriteLine($"Tenant ID: {config.TenantId}");
+ Console.WriteLine($"API URL: {config.ApiBaseUrl}");
+ Console.WriteLine();
+ }
+
+ using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger))
+ using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger))
+ {
+ // Step 1: Fetch remote config (gather rules + settings)
+ logger.Info("Fetching remote configuration...");
+ if (consoleMode) Console.WriteLine("Fetching remote configuration...");
+
+ try
+ {
+ remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15));
+ logger.Info("Remote config fetched successfully");
+ if (consoleMode) Console.WriteLine("Remote config fetched.");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"Failed to fetch remote config (continuing): {ex.Message}");
+ if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}");
+ }
+
+ var remoteConfig = remoteConfigService.CurrentConfig;
+ var rules = remoteConfig?.GatherRules;
+
+ var startupRules = rules?
+ .Where(r => r.Enabled && r.Trigger == "startup")
+ .ToList()
+ ?? new List();
+
+ if (startupRules.Count == 0)
+ {
+ logger.Info("No enabled startup gather rules found — nothing to execute");
+ if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute.");
+ return;
+ }
+
+ // Step 2: Register session (or re-use existing — backend handles both)
+ logger.Info("Registering session with backend...");
+ if (consoleMode) Console.WriteLine("Registering session...");
+
+ try
+ {
+ var registration = new SessionRegistration
+ {
+ SessionId = config.SessionId,
+ TenantId = config.TenantId,
+ SerialNumber = DeviceInfoProvider.GetSerialNumber(),
+ Manufacturer = DeviceInfoProvider.GetManufacturer(),
+ Model = DeviceInfoProvider.GetModel(),
+ DeviceName = Environment.MachineName,
+ OsBuild = Environment.OSVersion.Version.ToString(),
+ OsEdition = DeviceInfoProvider.GetOsEdition(),
+ OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
+ StartedAt = DateTime.UtcNow,
+ AgentVersion = agentVersion,
+ EnrollmentType = "gather_rules"
+ };
+
+ var regResponse = apiClient.RegisterSessionAsync(registration).Result;
+ if (regResponse.Success)
+ logger.Info($"Session registered: {regResponse.SessionId}");
+ else
+ logger.Warning($"Session registration: {regResponse.Message}");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"Session registration failed (continuing): {ex.Message}");
+ }
+
+ // Step 3: Collect events in-memory (avoids conflicting with a running agent's spool)
+ var collectedEvents = new List();
+ var evtLock = new object();
+ long sequence = 0;
+
+ Action emitEvent = evt =>
+ {
+ evt.Sequence = Interlocked.Increment(ref sequence);
+ lock (evtLock)
+ collectedEvents.Add(evt);
+ logger.Debug($"Gather event: {evt.EventType} — {evt.Message}");
+ if (consoleMode)
+ Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}");
+ };
+
+ // Step 4: Execute startup gather rules
+ logger.Info($"Executing {startupRules.Count} startup gather rule(s)...");
+ if (consoleMode)
+ Console.WriteLine($"Executing {startupRules.Count} startup gather rule(s)...");
+
+ emitEvent(new EnrollmentEvent
+ {
+ SessionId = config.SessionId,
+ TenantId = config.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "gather_rules_collection_started",
+ Severity = EventSeverity.Info,
+ Source = "Agent",
+ Phase = EnrollmentPhase.Start,
+ Message = $"Gather rules collection started ({startupRules.Count} rule(s))",
+ Data = new Dictionary
+ {
+ { "ruleCount", startupRules.Count },
+ { "agentVersion", agentVersion }
+ }
+ });
+
+ using (var executor = new GatherRuleExecutor(
+ config.SessionId,
+ config.TenantId,
+ emitEvent,
+ logger,
+ config.ImeLogPathOverride))
+ {
+ executor.UpdateRules(rules);
+
+ // Step 5: Wait for all startup rules to finish — no fixed sleep,
+ // exits as soon as the last rule completes (120 s hard cap).
+ const int maxWaitSeconds = 120;
+ logger.Info($"Waiting for startup rules to complete (max {maxWaitSeconds}s)...");
+ if (consoleMode)
+ Console.WriteLine($"Running {startupRules.Count} rule(s)...");
+
+ var allCompleted = executor.WaitForStartupRules(maxWaitSeconds);
+ if (!allCompleted)
+ {
+ logger.Warning($"Some gather rules did not complete within {maxWaitSeconds}s (results may be incomplete)");
+ if (consoleMode)
+ Console.WriteLine($"WARNING: Some rules timed out after {maxWaitSeconds}s.");
+ }
+ else
+ {
+ logger.Info("All startup rules completed");
+ if (consoleMode) Console.WriteLine("All rules completed.");
+ }
+ }
+
+ // Step 6: Upload all collected events
+ List eventsToUpload;
+ lock (evtLock)
+ eventsToUpload = new List(collectedEvents);
+
+ // +1 because the completed event itself is also uploaded
+ var totalEventCount = eventsToUpload.Count + 1;
+ emitEvent(new EnrollmentEvent
+ {
+ SessionId = config.SessionId,
+ TenantId = config.TenantId,
+ Timestamp = DateTime.UtcNow,
+ EventType = "gather_rules_collection_completed",
+ Severity = EventSeverity.Info,
+ Source = "Agent",
+ Phase = EnrollmentPhase.Start,
+ Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)",
+ Data = new Dictionary
+ {
+ { "totalEvents", totalEventCount }
+ }
+ });
+ // Re-snapshot after adding the completed event
+ lock (evtLock)
+ eventsToUpload = new List(collectedEvents);
+
+ logger.Info($"Uploading {eventsToUpload.Count} collected event(s)...");
+ if (consoleMode)
+ Console.WriteLine($"Uploading {eventsToUpload.Count} event(s)...");
+
+ if (eventsToUpload.Count > 0)
+ {
+ try
+ {
+ var request = new IngestEventsRequest
+ {
+ SessionId = config.SessionId,
+ TenantId = config.TenantId,
+ Events = eventsToUpload
+ };
+
+ var uploadResponse = apiClient.IngestEventsAsync(request).Result;
+ if (uploadResponse.Success)
+ {
+ logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully");
+ if (consoleMode)
+ Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully.");
+ }
+ else
+ {
+ logger.Warning($"Upload failed: {uploadResponse.Message}");
+ if (consoleMode)
+ Console.WriteLine($"Upload failed: {uploadResponse.Message}");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Failed to upload events", ex);
+ if (consoleMode)
+ Console.WriteLine($"ERROR uploading events: {ex.Message}");
+ }
+ }
+ else
+ {
+ logger.Info("No events were collected");
+ if (consoleMode)
+ Console.WriteLine("No events were collected.");
+ }
+ }
+
+ if (consoleMode)
+ Console.WriteLine("\nGather rules run complete.");
+ }
+ catch (Exception ex)
+ {
+ if (consoleMode)
+ {
+ Console.WriteLine($"ERROR: {ex.Message}");
+ Console.WriteLine(ex.StackTrace);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs
new file mode 100644
index 00000000..e261dbd7
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using AutopilotMonitor.Agent.Core.Configuration;
+using AutopilotMonitor.Agent.Core.Logging;
+using AutopilotMonitor.Shared;
+
+namespace AutopilotMonitor.Agent
+{
+ partial class Program
+ {
+ static void RunInstallMode(string[] args)
+ {
+ var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
+ var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
+ var consoleMode = args.Contains("--console") || Environment.UserInteractive;
+
+ try
+ {
+ logger.Info("======================= Agent install mode (--install) =======================");
+
+ EnsureAgentDirectories(logger);
+
+ var sourceExePath = Assembly.GetExecutingAssembly().Location;
+ var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
+ var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent"));
+ var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe");
+
+ if (!string.Equals(
+ Path.GetFullPath(sourceDir).TrimEnd('\\'),
+ Path.GetFullPath(targetAgentDir).TrimEnd('\\'),
+ StringComparison.OrdinalIgnoreCase))
+ {
+ logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'.");
+ CopyDirectory(sourceDir, targetAgentDir, logger);
+ }
+ else
+ {
+ logger.Info("Agent already running from target install directory; payload copy not required.");
+ }
+
+ // Persist bootstrap config if --bootstrap-token was provided.
+ // The Scheduled Task command line has no args, so the agent reads this file on restart.
+ var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
+ var tenantIdArg = GetArgValue(args, "--tenant-id");
+ if (!string.IsNullOrEmpty(bootstrapTokenArg))
+ {
+ var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
+ var bootstrapConfigPath = Path.Combine(dataDir, "bootstrap-config.json");
+ var bootstrapConfig = new BootstrapConfigFile
+ {
+ BootstrapToken = bootstrapTokenArg,
+ TenantId = tenantIdArg
+ };
+ File.WriteAllText(bootstrapConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(bootstrapConfig));
+ logger.Info("Bootstrap config persisted for Scheduled Task");
+ }
+
+ var taskName = Constants.ScheduledTaskName;
+ var taskCommand = $"\"{targetExePath}\"";
+
+ logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
+
+ var createExitCode = RunProcess(
+ "schtasks.exe",
+ $"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
+ logger);
+
+ if (createExitCode != 0)
+ {
+ throw new InvalidOperationException($"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})");
+ }
+
+ logger.Info($"Scheduled Task '{taskName}' created/updated successfully");
+
+ var startExitCode = RunProcess(
+ "schtasks.exe",
+ $"/Run /TN \"{taskName}\"",
+ logger);
+
+ if (startExitCode != 0)
+ {
+ throw new InvalidOperationException($"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})");
+ }
+
+ logger.Info($"Scheduled Task '{taskName}' started successfully");
+
+ if (consoleMode)
+ {
+ Console.WriteLine("Installation completed successfully.");
+ Console.WriteLine($"Task: {taskName}");
+ Console.WriteLine($"Executable: {targetExePath}");
+ Console.WriteLine($"Log: {logDir}");
+ }
+
+ Environment.Exit(0);
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Registration mode failed", ex);
+ if (consoleMode)
+ {
+ Console.WriteLine($"ERROR: {ex.Message}");
+ }
+ Environment.Exit(1);
+ }
+ }
+
+ private static void EnsureAgentDirectories(AgentLogger logger)
+ {
+ var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
+ var paths = new[]
+ {
+ basePath,
+ Path.Combine(basePath, "Agent"),
+ Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
+ Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
+ };
+
+ foreach (var path in paths)
+ {
+ if (!Directory.Exists(path))
+ {
+ Directory.CreateDirectory(path);
+ logger.Info($"Created directory: {path}");
+ }
+ else
+ {
+ logger.Debug($"Directory already exists: {path}");
+ }
+ }
+ }
+
+ private static int RunProcess(string fileName, string arguments, AgentLogger logger)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ logger.Info($"Executing: {fileName} {arguments}");
+
+ using (var process = Process.Start(psi))
+ {
+ if (process == null)
+ throw new InvalidOperationException($"Failed to start process: {fileName}");
+
+ var stdout = process.StandardOutput.ReadToEnd();
+ var stderr = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ if (!string.IsNullOrWhiteSpace(stdout))
+ logger.Info($"Process output: {stdout.Trim()}");
+ if (!string.IsNullOrWhiteSpace(stderr))
+ logger.Warning($"Process error output: {stderr.Trim()}");
+
+ logger.Info($"Process exit code: {process.ExitCode}");
+ return process.ExitCode;
+ }
+ }
+
+ private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger)
+ {
+ if (!Directory.Exists(sourceDir))
+ throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
+
+ Directory.CreateDirectory(targetDir);
+
+ foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var rel = dir.Substring(sourceDir.Length).TrimStart('\\');
+ Directory.CreateDirectory(Path.Combine(targetDir, rel));
+ }
+
+ foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var rel = file.Substring(sourceDir.Length).TrimStart('\\');
+ var dest = Path.Combine(targetDir, rel);
+ Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir);
+
+ try
+ {
+ File.Copy(file, dest, true);
+ }
+ catch (Exception ex)
+ {
+ // If destination exists, keep going and let scheduled task use the latest available payload.
+ if (File.Exists(dest))
+ {
+ logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file.");
+ continue;
+ }
+
+ throw;
+ }
+ }
+
+ logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'");
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs
index f1b87147..f5abbd55 100644
--- a/src/Agent/AutopilotMonitor.Agent/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent/Program.cs
@@ -1,22 +1,15 @@
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Reflection;
-using System.Threading;
using AutopilotMonitor.Agent.Core.Configuration;
using AutopilotMonitor.Agent.Core.Logging;
-using AutopilotMonitor.Agent.Core.Monitoring.Collectors;
using AutopilotMonitor.Agent.Core.Monitoring.Core;
-using AutopilotMonitor.Agent.Core.Monitoring.Network;
using AutopilotMonitor.Shared;
-using AutopilotMonitor.Shared.Models;
-using Microsoft.Win32;
namespace AutopilotMonitor.Agent
{
- class Program
+ partial class Program
{
static void Main(string[] args)
{
@@ -41,357 +34,6 @@ namespace AutopilotMonitor.Agent
RunAgent(args);
}
- static void RunInstallMode(string[] args)
- {
- var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
- var logger = new AgentLogger(logDir, AgentLogLevel.Debug);
- var consoleMode = args.Contains("--console") || Environment.UserInteractive;
-
- try
- {
- logger.Info("======================= Agent install mode (--install) =======================");
-
- EnsureAgentDirectories(logger);
-
- var sourceExePath = Assembly.GetExecutingAssembly().Location;
- var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
- var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent"));
- var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe");
-
- if (!string.Equals(
- Path.GetFullPath(sourceDir).TrimEnd('\\'),
- Path.GetFullPath(targetAgentDir).TrimEnd('\\'),
- StringComparison.OrdinalIgnoreCase))
- {
- logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'.");
- CopyDirectory(sourceDir, targetAgentDir, logger);
- }
- else
- {
- logger.Info("Agent already running from target install directory; payload copy not required.");
- }
-
- // Persist bootstrap config if --bootstrap-token was provided.
- // The Scheduled Task command line has no args, so the agent reads this file on restart.
- var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
- var tenantIdArg = GetArgValue(args, "--tenant-id");
- if (!string.IsNullOrEmpty(bootstrapTokenArg))
- {
- var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
- var bootstrapConfigPath = Path.Combine(dataDir, "bootstrap-config.json");
- var bootstrapConfig = new BootstrapConfigFile
- {
- BootstrapToken = bootstrapTokenArg,
- TenantId = tenantIdArg
- };
- File.WriteAllText(bootstrapConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(bootstrapConfig));
- logger.Info("Bootstrap config persisted for Scheduled Task");
- }
-
- var taskName = Constants.ScheduledTaskName;
- var taskCommand = $"\"{targetExePath}\"";
-
- logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}");
-
- var createExitCode = RunProcess(
- "schtasks.exe",
- $"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F",
- logger);
-
- if (createExitCode != 0)
- {
- throw new InvalidOperationException($"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})");
- }
-
- logger.Info($"Scheduled Task '{taskName}' created/updated successfully");
-
- var startExitCode = RunProcess(
- "schtasks.exe",
- $"/Run /TN \"{taskName}\"",
- logger);
-
- if (startExitCode != 0)
- {
- throw new InvalidOperationException($"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})");
- }
-
- logger.Info($"Scheduled Task '{taskName}' started successfully");
-
- if (consoleMode)
- {
- Console.WriteLine("Installation completed successfully.");
- Console.WriteLine($"Task: {taskName}");
- Console.WriteLine($"Executable: {targetExePath}");
- Console.WriteLine($"Log: {logDir}");
- }
-
- Environment.Exit(0);
- }
- catch (Exception ex)
- {
- logger.Error("Registration mode failed", ex);
- if (consoleMode)
- {
- Console.WriteLine($"ERROR: {ex.Message}");
- }
- Environment.Exit(1);
- }
- }
-
- ///
- /// Fetches the latest remote config, executes all startup gather rules against the
- /// current session (or a freshly registered one if no session.id exists), uploads the
- /// collected events, then exits. Intended for manual testing and debugging.
- ///
- static void RunGatherRulesMode(string[] args)
- {
- var consoleMode = args.Contains("--console") || Environment.UserInteractive;
-
- if (consoleMode)
- {
- Console.WriteLine("Autopilot Monitor Agent — Run Gather Rules");
- Console.WriteLine("==========================================");
- Console.WriteLine();
- }
-
- try
- {
- var config = LoadConfiguration(args);
- var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory);
- var logger = new AgentLogger(logDir, config.LogLevel);
- var agentVersion = GetAgentVersion();
-
- // Always use a fresh session so gather-rules runs appear as a
- // distinct portal entry and never pollute an active enrollment session.
- config.SessionId = Guid.NewGuid().ToString();
-
- logger.Info("======================= --run-gather-rules mode =======================");
-
- if (consoleMode)
- {
- Console.WriteLine($"Session ID: {config.SessionId} (new, ephemeral)");
- Console.WriteLine($"Tenant ID: {config.TenantId}");
- Console.WriteLine($"API URL: {config.ApiBaseUrl}");
- Console.WriteLine();
- }
-
- using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger))
- using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger))
- {
- // Step 1: Fetch remote config (gather rules + settings)
- logger.Info("Fetching remote configuration...");
- if (consoleMode) Console.WriteLine("Fetching remote configuration...");
-
- try
- {
- remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15));
- logger.Info("Remote config fetched successfully");
- if (consoleMode) Console.WriteLine("Remote config fetched.");
- }
- catch (Exception ex)
- {
- logger.Warning($"Failed to fetch remote config (continuing): {ex.Message}");
- if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}");
- }
-
- var remoteConfig = remoteConfigService.CurrentConfig;
- var rules = remoteConfig?.GatherRules;
-
- var startupRules = rules?
- .Where(r => r.Enabled && r.Trigger == "startup")
- .ToList()
- ?? new List();
-
- if (startupRules.Count == 0)
- {
- logger.Info("No enabled startup gather rules found — nothing to execute");
- if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute.");
- return;
- }
-
- // Step 2: Register session (or re-use existing — backend handles both)
- logger.Info("Registering session with backend...");
- if (consoleMode) Console.WriteLine("Registering session...");
-
- try
- {
- var registration = new SessionRegistration
- {
- SessionId = config.SessionId,
- TenantId = config.TenantId,
- SerialNumber = DeviceInfoProvider.GetSerialNumber(),
- Manufacturer = DeviceInfoProvider.GetManufacturer(),
- Model = DeviceInfoProvider.GetModel(),
- DeviceName = Environment.MachineName,
- OsBuild = Environment.OSVersion.Version.ToString(),
- OsEdition = DeviceInfoProvider.GetOsEdition(),
- OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name,
- StartedAt = DateTime.UtcNow,
- AgentVersion = agentVersion,
- EnrollmentType = "gather_rules"
- };
-
- var regResponse = apiClient.RegisterSessionAsync(registration).Result;
- if (regResponse.Success)
- logger.Info($"Session registered: {regResponse.SessionId}");
- else
- logger.Warning($"Session registration: {regResponse.Message}");
- }
- catch (Exception ex)
- {
- logger.Warning($"Session registration failed (continuing): {ex.Message}");
- }
-
- // Step 3: Collect events in-memory (avoids conflicting with a running agent's spool)
- var collectedEvents = new List();
- var evtLock = new object();
- long sequence = 0;
-
- Action emitEvent = evt =>
- {
- evt.Sequence = Interlocked.Increment(ref sequence);
- lock (evtLock)
- collectedEvents.Add(evt);
- logger.Debug($"Gather event: {evt.EventType} — {evt.Message}");
- if (consoleMode)
- Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}");
- };
-
- // Step 4: Execute startup gather rules
- logger.Info($"Executing {startupRules.Count} startup gather rule(s)...");
- if (consoleMode)
- Console.WriteLine($"Executing {startupRules.Count} startup gather rule(s)...");
-
- emitEvent(new EnrollmentEvent
- {
- SessionId = config.SessionId,
- TenantId = config.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "gather_rules_collection_started",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = EnrollmentPhase.Start,
- Message = $"Gather rules collection started ({startupRules.Count} rule(s))",
- Data = new Dictionary
- {
- { "ruleCount", startupRules.Count },
- { "agentVersion", agentVersion }
- }
- });
-
- using (var executor = new GatherRuleExecutor(
- config.SessionId,
- config.TenantId,
- emitEvent,
- logger,
- config.ImeLogPathOverride))
- {
- executor.UpdateRules(rules);
-
- // Step 5: Wait for all startup rules to finish — no fixed sleep,
- // exits as soon as the last rule completes (120 s hard cap).
- const int maxWaitSeconds = 120;
- logger.Info($"Waiting for startup rules to complete (max {maxWaitSeconds}s)...");
- if (consoleMode)
- Console.WriteLine($"Running {startupRules.Count} rule(s)...");
-
- var allCompleted = executor.WaitForStartupRules(maxWaitSeconds);
- if (!allCompleted)
- {
- logger.Warning($"Some gather rules did not complete within {maxWaitSeconds}s (results may be incomplete)");
- if (consoleMode)
- Console.WriteLine($"WARNING: Some rules timed out after {maxWaitSeconds}s.");
- }
- else
- {
- logger.Info("All startup rules completed");
- if (consoleMode) Console.WriteLine("All rules completed.");
- }
- }
-
- // Step 6: Upload all collected events
- List eventsToUpload;
- lock (evtLock)
- eventsToUpload = new List(collectedEvents);
-
- // +1 because the completed event itself is also uploaded
- var totalEventCount = eventsToUpload.Count + 1;
- emitEvent(new EnrollmentEvent
- {
- SessionId = config.SessionId,
- TenantId = config.TenantId,
- Timestamp = DateTime.UtcNow,
- EventType = "gather_rules_collection_completed",
- Severity = EventSeverity.Info,
- Source = "Agent",
- Phase = EnrollmentPhase.Start,
- Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)",
- Data = new Dictionary
- {
- { "totalEvents", totalEventCount }
- }
- });
- // Re-snapshot after adding the completed event
- lock (evtLock)
- eventsToUpload = new List(collectedEvents);
-
- logger.Info($"Uploading {eventsToUpload.Count} collected event(s)...");
- if (consoleMode)
- Console.WriteLine($"Uploading {eventsToUpload.Count} event(s)...");
-
- if (eventsToUpload.Count > 0)
- {
- try
- {
- var request = new IngestEventsRequest
- {
- SessionId = config.SessionId,
- TenantId = config.TenantId,
- Events = eventsToUpload
- };
-
- var uploadResponse = apiClient.IngestEventsAsync(request).Result;
- if (uploadResponse.Success)
- {
- logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully");
- if (consoleMode)
- Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully.");
- }
- else
- {
- logger.Warning($"Upload failed: {uploadResponse.Message}");
- if (consoleMode)
- Console.WriteLine($"Upload failed: {uploadResponse.Message}");
- }
- }
- catch (Exception ex)
- {
- logger.Error("Failed to upload events", ex);
- if (consoleMode)
- Console.WriteLine($"ERROR uploading events: {ex.Message}");
- }
- }
- else
- {
- logger.Info("No events were collected");
- if (consoleMode)
- Console.WriteLine("No events were collected.");
- }
- }
-
- if (consoleMode)
- Console.WriteLine("\nGather rules run complete.");
- }
- catch (Exception ex)
- {
- if (consoleMode)
- {
- Console.WriteLine($"ERROR: {ex.Message}");
- Console.WriteLine(ex.StackTrace);
- }
- }
- }
-
static void RunAgent(string[] args)
{
var consoleMode = args.Contains("--console") || Environment.UserInteractive;
@@ -510,301 +152,6 @@ namespace AutopilotMonitor.Agent
}
}
- static string GetTenantIdFromRegistry()
- {
- try
- {
- const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
-
- using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
- {
- if (enrollmentsKey == null)
- return null;
-
- foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames())
- {
- using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid))
- {
- if (enrollmentKey == null) continue;
-
- var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
- if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6)
- {
- var tenantId = enrollmentKey.GetValue("AADTenantID");
- if (tenantId != null)
- return tenantId.ToString();
- }
- }
- }
- }
- }
- catch { }
-
- return null;
- }
-
- static AgentConfiguration LoadConfiguration(string[] args = null)
- {
- var noAuth = args?.Contains("--no-auth") ?? false;
- var noCleanup = args?.Contains("--no-cleanup") ?? false;
- var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
- var disableGeoLoc = args?.Contains("--disable-geolocation") ?? false;
- var newSession = args?.Contains("--new-session") ?? false;
- var keepLogFile = args?.Contains("--keep-logfile") ?? false;
-
- string certThumbprint = null;
- string tenantIdOverride = null;
- string apiBaseUrlOverride = null;
- string imeLogPathOverride = null;
- string imeMatchLogPath = null;
- string replayLogDir = null;
- string bootstrapToken = null;
- double replaySpeedFactor = 50;
-
- if (args != null)
- {
- var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
- if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length)
- certThumbprint = args[thumbprintIndex + 1];
-
- var tenantIdIndex = Array.IndexOf(args, "--tenant-id");
- if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length)
- tenantIdOverride = args[tenantIdIndex + 1];
-
- // Local backend URL override for debugging/testing.
- // Supported aliases:
- // --api-url https://...
- // --backend-api https://...
- apiBaseUrlOverride = GetArgValue(args, "--api-url", "--backend-api");
-
- var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path");
- if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length)
- imeLogPathOverride = args[imeLogPathIndex + 1];
-
- var imeMatchLogIndex = Array.IndexOf(args, "--ime-match-log");
- if (imeMatchLogIndex >= 0 && imeMatchLogIndex + 1 < args.Length)
- imeMatchLogPath = args[imeMatchLogIndex + 1];
-
- var replayLogDirIndex = Array.IndexOf(args, "--replay-log-dir");
- if (replayLogDirIndex >= 0 && replayLogDirIndex + 1 < args.Length)
- replayLogDir = args[replayLogDirIndex + 1];
-
- var replaySpeedIndex = Array.IndexOf(args, "--replay-speed-factor");
- if (replaySpeedIndex >= 0 && replaySpeedIndex + 1 < args.Length)
- if (double.TryParse(args[replaySpeedIndex + 1], out var speed))
- replaySpeedFactor = speed;
-
- var bootstrapTokenIndex = Array.IndexOf(args, "--bootstrap-token");
- if (bootstrapTokenIndex >= 0 && bootstrapTokenIndex + 1 < args.Length)
- bootstrapToken = args[bootstrapTokenIndex + 1];
- }
-
- // Defaults
- string apiBaseUrl = Constants.ApiBaseUrl;
- int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds;
- bool selfDestructOnComplete = false;
- bool rebootOnCompleteConfig = false;
- bool enableGeoLocationConfig = true;
- bool useClientCertAuthConfig = true;
-
- // Environment variable overrides built-in default
- apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl;
- // CLI override wins over environment variable/default
- if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride))
- apiBaseUrl = apiBaseUrlOverride;
-
- if (noCleanup)
- {
- selfDestructOnComplete = false;
- }
-
- // Determine TenantId: command-line arg > registry > fallback
- string tenantId;
- if (!string.IsNullOrEmpty(tenantIdOverride))
- {
- tenantId = tenantIdOverride;
- }
- else
- {
- tenantId = GetTenantIdFromRegistry();
- }
-
- var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
- var sessionPersist = new SessionPersistence(dataDirectory);
-
- if (newSession)
- sessionPersist.DeleteSession();
-
- var sessionId = sessionPersist.LoadOrCreateSessionId();
-
- // Bootstrap config: if no --bootstrap-token CLI arg, check for persisted config file.
- // The OOBE bootstrap script calls --install --bootstrap-token {TOKEN} --tenant-id {TENANTID}.
- // RunInstallMode persists these to bootstrap-config.json so the Scheduled Task picks them up
- // on restart (the task command line has no args — just the exe path).
- var bootstrapConfigPath = Path.Combine(dataDirectory, "bootstrap-config.json");
- if (string.IsNullOrEmpty(bootstrapToken) && File.Exists(bootstrapConfigPath))
- {
- try
- {
- var json = File.ReadAllText(bootstrapConfigPath);
- var bootstrapConfig = Newtonsoft.Json.JsonConvert.DeserializeObject(json);
- if (bootstrapConfig != null)
- {
- bootstrapToken = bootstrapConfig.BootstrapToken;
- if (string.IsNullOrEmpty(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId))
- {
- tenantId = bootstrapConfig.TenantId;
- }
- // Also set noAuth so cert search is skipped
- noAuth = true;
- }
- }
- catch { /* ignore corrupt file */ }
- }
-
- var useBootstrapToken = !string.IsNullOrEmpty(bootstrapToken);
-
- return new AgentConfiguration
- {
- ApiBaseUrl = apiBaseUrl,
- SessionId = sessionId,
- TenantId = tenantId,
- SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
- LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
- UploadIntervalSeconds = uploadIntervalSeconds,
- MaxBatchSize = Constants.MaxBatchSize,
- LogLevel = AgentLogLevel.Info,
- UseClientCertAuth = !noAuth && !useBootstrapToken && useClientCertAuthConfig,
- ClientCertThumbprint = certThumbprint,
- SelfDestructOnComplete = selfDestructOnComplete,
- RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
- EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig,
- ImeLogPathOverride = imeLogPathOverride,
- ImeMatchLogPath = imeMatchLogPath,
- ReplayLogDir = replayLogDir,
- ReplaySpeedFactor = replaySpeedFactor,
- KeepLogFile = keepLogFile,
- BootstrapToken = bootstrapToken,
- UseBootstrapTokenAuth = useBootstrapToken
- };
- }
-
- private static string GetArgValue(string[] args, params string[] names)
- {
- if (args == null || names == null || names.Length == 0)
- return null;
-
- for (int i = 0; i < args.Length - 1; i++)
- {
- foreach (var name in names)
- {
- if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
- {
- return args[i + 1];
- }
- }
- }
-
- return null;
- }
-
- private static void EnsureAgentDirectories(AgentLogger logger)
- {
- var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
- var paths = new[]
- {
- basePath,
- Path.Combine(basePath, "Agent"),
- Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
- Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
- };
-
- foreach (var path in paths)
- {
- if (!Directory.Exists(path))
- {
- Directory.CreateDirectory(path);
- logger.Info($"Created directory: {path}");
- }
- else
- {
- logger.Debug($"Directory already exists: {path}");
- }
- }
- }
-
- private static int RunProcess(string fileName, string arguments, AgentLogger logger)
- {
- var psi = new ProcessStartInfo
- {
- FileName = fileName,
- Arguments = arguments,
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- logger.Info($"Executing: {fileName} {arguments}");
-
- using (var process = Process.Start(psi))
- {
- if (process == null)
- throw new InvalidOperationException($"Failed to start process: {fileName}");
-
- var stdout = process.StandardOutput.ReadToEnd();
- var stderr = process.StandardError.ReadToEnd();
- process.WaitForExit();
-
- if (!string.IsNullOrWhiteSpace(stdout))
- logger.Info($"Process output: {stdout.Trim()}");
- if (!string.IsNullOrWhiteSpace(stderr))
- logger.Warning($"Process error output: {stderr.Trim()}");
-
- logger.Info($"Process exit code: {process.ExitCode}");
- return process.ExitCode;
- }
- }
-
- private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger)
- {
- if (!Directory.Exists(sourceDir))
- throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}");
-
- Directory.CreateDirectory(targetDir);
-
- foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
- {
- var rel = dir.Substring(sourceDir.Length).TrimStart('\\');
- Directory.CreateDirectory(Path.Combine(targetDir, rel));
- }
-
- foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
- {
- var rel = file.Substring(sourceDir.Length).TrimStart('\\');
- var dest = Path.Combine(targetDir, rel);
- Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir);
-
- try
- {
- File.Copy(file, dest, true);
- }
- catch (Exception ex)
- {
- // If destination exists, keep going and let scheduled task use the latest available payload.
- if (File.Exists(dest))
- {
- logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file.");
- continue;
- }
-
- throw;
- }
- }
-
- logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'");
- }
-
///
/// Checks if another AutopilotMonitor.Agent.exe process is already running.
///
@@ -824,161 +171,6 @@ namespace AutopilotMonitor.Agent
return false;
}
}
-
- ///
- /// Checks for enrollment complete marker and handles cleanup retry if needed.
- /// Returns true if marker was found and agent should exit.
- ///
- static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode)
- {
- var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
- var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
-
- if (!File.Exists(markerPath))
- {
- // No marker - proceed with normal enrollment
- return false;
- }
-
- logger.Info("Enrollment complete marker detected from previous session");
-
- if (!config.SelfDestructOnComplete)
- {
- // No cleanup configured - just exit
- logger.Info("Enrollment already completed (SelfDestructOnComplete is disabled). Agent will exit.");
- if (consoleMode)
- Console.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit.");
- return true;
- }
-
- // Self-destruct is configured - attempt it now in case scheduled task failed
- logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)...");
- if (consoleMode)
- Console.WriteLine("Enrollment already completed. Attempting cleanup retry...");
-
- try
- {
- using (var service = new MonitoringService(config, logger, GetAgentVersion()))
- {
- service.TriggerCleanup();
- }
-
- logger.Info("Cleanup retry completed. Agent will exit.");
- if (consoleMode)
- Console.WriteLine("Cleanup retry completed. Agent will exit.");
- }
- catch (Exception ex)
- {
- logger.Warning($"Cleanup retry failed: {ex.Message}");
- if (consoleMode)
- Console.WriteLine($"WARNING: Cleanup retry failed: {ex.Message}");
- }
-
- return true;
- }
-
- ///
- /// Emergency break: checks if the current session has been alive longer than the absolute maximum.
- /// Prevents zombie agents that stay on the device forever due to logic errors preventing
- /// normal enrollment completion. Respects WhiteGlove scenarios (skips check during resume).
- /// Returns true if emergency break triggered and agent should exit.
- ///
- static bool CheckSessionAgeEmergencyBreak(AgentConfiguration config, AgentLogger logger, bool consoleMode)
- {
- try
- {
- var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
- var persistence = new SessionPersistence(dataDir);
-
- // Skip check if WhiteGlove-paused (device powered off between Part 1 and Part 2).
- // The timer will be reset when MonitoringService.Start() processes the WhiteGlove resume.
- if (persistence.IsWhiteGloveResume())
- {
- logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check");
- return false;
- }
-
- var sessionCreatedAt = persistence.LoadSessionCreatedAt();
- if (sessionCreatedAt == null)
- {
- // Older agent without session.created — initialize the file now.
- // The emergency break will only trigger on subsequent restarts.
- if (persistence.SessionExists())
- {
- persistence.SaveSessionCreatedAt(DateTime.UtcNow);
- logger.Info("Emergency break: Initialized session.created for existing session");
- }
- return false;
- }
-
- var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours;
- var maxAgeHours = config.AbsoluteMaxSessionHours;
-
- if (sessionAgeHours <= maxAgeHours)
- {
- logger.Info($"Emergency break: Session age {sessionAgeHours:F1}h within limit ({maxAgeHours}h)");
- return false;
- }
-
- // Session too old — emergency self-destruct
- logger.Warning($"EMERGENCY BREAK: Session age {sessionAgeHours:F1}h exceeds maximum {maxAgeHours}h — forcing cleanup");
-
- if (consoleMode)
- Console.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {maxAgeHours}h). Forcing cleanup.");
-
- // Write enrollment-complete marker so next start exits cleanly
- var stateDir = Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
- Directory.CreateDirectory(stateDir);
- var markerPath = Path.Combine(stateDir, "enrollment-complete.marker");
- File.WriteAllText(markerPath, $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)");
-
- if (config.SelfDestructOnComplete)
- {
- using (var service = new MonitoringService(config, logger, GetAgentVersion()))
- {
- service.TriggerCleanup();
- }
- }
-
- // Clean up session files
- persistence.DeleteSession();
-
- logger.Info("Emergency break: Cleanup completed. Agent will exit.");
- return true;
- }
- catch (Exception ex)
- {
- logger.Error($"Emergency break check failed: {ex.Message}", ex);
- return false; // Don't block startup on check failure
- }
- }
-
- static string GetAgentVersion()
- {
- try
- {
- var assembly = Assembly.GetExecutingAssembly();
- var informationalVersion = assembly
- .GetCustomAttribute()
- ?.InformationalVersion;
-
- if (!string.IsNullOrWhiteSpace(informationalVersion))
- return informationalVersion;
-
- var fileVersion = assembly
- .GetCustomAttribute()
- ?.Version;
-
- if (!string.IsNullOrWhiteSpace(fileVersion))
- return fileVersion;
-
- return assembly.GetName().Version?.ToString() ?? "unknown";
- }
- catch
- {
- return "unknown";
- }
- }
}
///
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 77f83230..67f3f23e 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-243
+270
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
index 206d229f..5802c571 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions;
+namespace AutopilotMonitor.Functions.Functions.Admin;
public class AutopilotDeviceValidationConsentFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs
index 859a393c..81cf7659 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs
@@ -3,7 +3,7 @@ using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
///
/// Timer-triggered Azure Function that runs daily maintenance.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs
index b2591b84..619ce527 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs
@@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
public class DeviceBlockFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs
index 654536e4..ead851e2 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetAllBlockedDevicesFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
index 00acb555..277d5a65 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetAuditLogsFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs
index 05ca3fe6..088a3fc8 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
public class GetGalacticAuditLogsFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs
index 14ef9aae..4f1a70db 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
///
/// Central reseed endpoint that fetches all rule types from GitHub and writes them to Table Storage.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs
index 27affe6e..e4a2705b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions;
+namespace AutopilotMonitor.Functions.Functions.Admin;
///
/// Tenant Admin Management endpoints
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs
index 1f594137..2a54aad5 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs
@@ -11,7 +11,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions;
+namespace AutopilotMonitor.Functions.Functions.Admin;
///
/// Tenant Offboarding endpoint
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
index db01ef3c..2ffad423 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Admin
{
///
/// Allows Galactic Admins to manually trigger maintenance tasks
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs
index ea28f037..ba94daab 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs
@@ -1,12 +1,13 @@
using System;
using System.Net;
+using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// GET /api/bootstrap/config — cert-free agent config for pre-enrollment agents.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs
index b54b576c..f87d1bee 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs
@@ -1,5 +1,6 @@
using System;
using System.Net;
+using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
@@ -7,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// POST /api/bootstrap/ingest — cert-free ingest for pre-enrollment agents.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs
index 057fddf0..0ca031ac 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs
@@ -1,5 +1,6 @@
using System;
using System.Net;
+using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
@@ -9,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// POST /api/bootstrap/register-session — cert-free session registration for pre-enrollment agents.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs
index 79389570..b3d35280 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs
@@ -1,12 +1,13 @@
using System;
using System.Net;
+using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// POST /api/bootstrap/error — cert-free error reporting for pre-enrollment agents.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs
index 576fcd19..e8bc5c94 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// POST /api/bootstrap/sessions — Create a new bootstrap session for OOBE agent deployment.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs
index b66896de..9b96f357 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs
@@ -9,7 +9,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// GET /api/bootstrap/sessions?tenantId={tenantId} — List bootstrap sessions for a tenant.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs
index f99cccac..cf6e4b67 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// DELETE /api/bootstrap/sessions/{code}?tenantId={tenantId} — Revoke a bootstrap session.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs
index 04b8bbb1..d46b97f7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs
@@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
///
/// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs
index 47b9d4dd..78c8667f 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetAdminConfigurationFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
index cbb3f04c..1832da8b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
///
/// Returns agent configuration including collector toggles and active gather rules
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs
index 45e92677..daf0a32b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetAllTenantConfigurationsFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs
index 9e0b2a2d..3dca215f 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class GetTenantConfigurationFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs
index bfe1367c..27ed68dd 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateAdminConfigurationFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
index 1cb3bf8b..00200bd8 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
@@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateTenantConfigurationFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs
index 340064f7..11725009 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Config
{
public class UpdateTenantSecurityBypassFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs
index b9f0b7ea..335ef51f 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
///
/// Returns a download URL for a diagnostics package stored in the tenant's Blob Storage.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs
index c8438772..c164af17 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
///
/// Returns a short-lived SAS URL for diagnostics package upload.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
index 4a2e83ba..d4538826 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions;
+namespace AutopilotMonitor.Functions.Functions.Infrastructure;
///
/// Authentication and authorization endpoints
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs
index 1d5e7802..9e22b7af 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs
@@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class HealthCheckFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
index b3094331..9190224d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRAddToGroupFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs
index c3e1a179..15922efd 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs
@@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRNegotiateFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
index 19e3274c..553fb225 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
@@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-namespace AutopilotMonitor.Functions.Functions
+namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRRemoveFromGroupFunction
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs
new file mode 100644
index 00000000..02064089
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs
@@ -0,0 +1,303 @@
+using System.IO.Compression;
+using System.Linq;
+using System;
+using System.Net;
+using System.Text;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Azure.Functions.Worker;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace AutopilotMonitor.Functions.Functions.Ingest
+{
+ ///
+ /// Partial: Request parsing, event normalization, app install aggregation helpers.
+ ///
+ public partial class IngestEventsFunction
+ {
+ private async Task ParseNdjsonGzipRequest(Stream body, string? tenantId = null)
+ {
+ // Get configuration for payload size limit (use default if tenantId not available yet)
+ var config = !string.IsNullOrEmpty(tenantId)
+ ? await _configService.GetConfigurationAsync(tenantId)
+ : null;
+
+ var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024;
+
+ // Decompress gzip with size limit protection
+ using var decompressed = new MemoryStream();
+ using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true))
+ {
+ // Copy with size limit to prevent memory exhaustion attacks
+ var buffer = new byte[8192];
+ int bytesRead;
+ long totalBytesRead = 0;
+
+ while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ totalBytesRead += bytesRead;
+
+ // Check if we've exceeded the maximum payload size
+ if (totalBytesRead > maxPayloadSizeBytes)
+ {
+ throw new InvalidOperationException(
+ $"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " +
+ $"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB"
+ );
+ }
+
+ await decompressed.WriteAsync(buffer, 0, bytesRead);
+ }
+
+ _logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)");
+ }
+
+ decompressed.Position = 0;
+ var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync();
+
+ // Parse NDJSON (newline-delimited JSON)
+ var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ if (lines.Length < 1)
+ {
+ throw new InvalidOperationException("NDJSON must contain at least metadata line");
+ }
+
+ // First line: metadata
+ var metadata = JsonConvert.DeserializeObject(lines[0]);
+ if (metadata == null)
+ {
+ throw new InvalidOperationException("Failed to parse NDJSON metadata");
+ }
+
+ // Subsequent lines: events
+ var events = new List();
+ for (int i = 1; i < lines.Length; i++)
+ {
+ var evt = JsonConvert.DeserializeObject(lines[i]);
+ if (evt != null)
+ {
+ NormalizeEventData(evt);
+ events.Add(evt);
+ }
+ }
+
+ return new IngestEventsRequest
+ {
+ SessionId = metadata.SessionId,
+ TenantId = metadata.TenantId,
+ Events = events
+ };
+ }
+
+ ///
+ /// Normalizes event Data dictionary by converting Newtonsoft JToken objects to native .NET types.
+ /// Required because Newtonsoft.Json deserializes nested objects as JObject/JArray, which
+ /// System.Text.Json (used by SignalR) cannot serialize correctly - producing [[[]]] instead of real values.
+ ///
+ private static void NormalizeEventData(EnrollmentEvent evt)
+ {
+ if (evt.Data == null || evt.Data.Count == 0) return;
+ var normalized = new Dictionary();
+ foreach (var kvp in evt.Data)
+ normalized[kvp.Key] = ConvertJTokenToNative(kvp.Value);
+ evt.Data = normalized;
+ }
+
+ private static object ConvertJTokenToNative(object value)
+ {
+ if (value is JArray jArray)
+ return jArray.Select(item => ConvertJTokenToNative(item)).ToList