diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs index d27cdb32..17bb459a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -139,6 +139,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.True(captured.IsUserDriven); } + [Fact] + public async Task Uses_supplied_device_hardware_instead_of_querying_wmi() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + SessionRegistration captured = null!; + var apiClient = new FakeApiClient( + _ => new RegisterSessionResponse { Success = true, SessionId = config.SessionId }, + onRegister: reg => captured = reg); + + // Simulates the auth bundle's already-hardened hardware read being passed through. + // The body must carry these verbatim rather than re-reading WMI on the test host. + await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "9.9.9", logger: logger, + deviceHardware: ("Contoso Devices", "Contoso Book 3", "SN-TEST-12345")); + + Assert.NotNull(captured); + Assert.Equal("Contoso Devices", captured.Manufacturer); + Assert.Equal("Contoso Book 3", captured.Model); + Assert.Equal("SN-TEST-12345", captured.SerialNumber); + } + // Subclasses the real BackendApiClient so we inherit its public API but intercept the // single virtual entry point that matters. BackendApiClient's ctor is protected-less in // V2 (`public BackendApiClient(string baseUrl, ...)` + `protected BackendApiClient()`); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs index 4e3ba815..9a643d1c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs @@ -1,5 +1,6 @@ using System; using System.Management; +using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; namespace AutopilotMonitor.Agent.V2.Core.Security @@ -9,22 +10,82 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// public static class HardwareInfo { + /// Sentinel returned per field when WMI cannot supply a value. + private const string Unknown = "Unknown"; + /// - /// Gets the device manufacturer and model from WMI + /// Gets the device manufacturer, model and serial number from WMI. + /// + /// The Winmgmt (WMI) service is occasionally unavailable for a brief window during OOBE — + /// exactly when the agent first starts and reads hardware for the security headers AND the + /// registration row. A single failed read would poison both with "Unknown" (observed: + /// session rows with Manufacturer/Model/SerialNumber = "Unknown" while the registry-based + /// OS fields were correct). This one read is the single source of truth (headers, telemetry + /// and — via the auth bundle — the registration body all reuse it), so it retries once after + /// a short delay when any field is still unresolved. Per-field merge keeps a value that + /// resolved on an earlier attempt even if a later query transiently fails. + /// /// /// Logger for diagnostic output + /// Total read attempts (default 2 → one retry). Overridable for tests. + /// Delay between attempts in milliseconds. Overridable for tests. /// Tuple of (Manufacturer, Model, SerialNumber) - public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null) + public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo( + AgentLogger logger = null, + int maxAttempts = 2, + int retryDelayMs = 1500) { - string manufacturer = "Unknown"; - string model = "Unknown"; - string serialNumber = "Unknown"; + string manufacturer = Unknown; + string model = Unknown; + string serialNumber = Unknown; + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + var read = TryReadHardware(logger, attempt); + + // Per-field merge: never overwrite an already-resolved value with a later "Unknown". + if (IsResolved(read.Manufacturer)) manufacturer = read.Manufacturer; + if (IsResolved(read.Model)) model = read.Model; + if (IsResolved(read.SerialNumber)) serialNumber = read.SerialNumber; + + if (IsResolved(manufacturer) && IsResolved(model) && IsResolved(serialNumber)) + { + if (attempt > 1) + logger?.Info($"Hardware fully resolved on attempt {attempt}/{maxAttempts}."); + return (manufacturer, model, serialNumber); + } + + if (attempt < maxAttempts) + { + logger?.Warning( + $"Hardware read incomplete (Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}) — " + + $"WMI may not be ready yet. Retrying in {retryDelayMs}ms (attempt {attempt}/{maxAttempts})."); + if (retryDelayMs > 0) + Thread.Sleep(retryDelayMs); + } + } + + logger?.Warning( + $"Hardware read still incomplete after {maxAttempts} attempt(s): " + + $"Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}."); + return (manufacturer, model, serialNumber); + } + + /// + /// A single best-effort WMI read. Never throws. Each query is isolated so a failure reading + /// the BIOS serial does not discard a manufacturer/model that was already read successfully. + /// + private static (string Manufacturer, string Model, string SerialNumber) TryReadHardware(AgentLogger logger, int attempt) + { + string manufacturer = Unknown; + string model = Unknown; + string serialNumber = Unknown; + + // Manufacturer + Model from Win32_ComputerSystem. + // Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version + // instead of Win32_ComputerSystem.Model (which contains a generic platform string). try { - // Get Manufacturer and Model from Win32_ComputerSystem - // Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version - // instead of Win32_ComputerSystem.Model (which contains a generic platform string) using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem")) using (var collection = searcher.Get()) { @@ -32,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { using (obj) { - manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown"; + manufacturer = obj["Manufacturer"]?.ToString() ?? Unknown; if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) { using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) @@ -42,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { using (lenovoObj) { - model = lenovoObj["Version"]?.ToString() ?? "Unknown"; + model = lenovoObj["Version"]?.ToString() ?? Unknown; } break; } @@ -50,13 +111,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } else { - model = obj["Model"]?.ToString() ?? "Unknown"; + model = obj["Model"]?.ToString() ?? Unknown; } } } } + } + catch (Exception ex) + { + logger?.Debug($"Hardware read: Win32_ComputerSystem query failed (attempt {attempt}): {ex.Message}"); + } - // Get Serial Number from Win32_BIOS + // Serial number from Win32_BIOS — isolated so a failure here keeps manufacturer/model. + try + { using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) using (var collection = searcher.Get()) { @@ -64,19 +132,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { using (obj) { - serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown"; + serialNumber = obj["SerialNumber"]?.ToString() ?? Unknown; } } } - - logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}"); - return (manufacturer, model, serialNumber); } catch (Exception ex) { - logger?.Error("Error retrieving hardware information", ex); - return ("Unknown", "Unknown", "Unknown"); + logger?.Debug($"Hardware read: Win32_BIOS query failed (attempt {attempt}): {ex.Message}"); } + + logger?.Debug($"Hardware detected (attempt {attempt}): Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}"); + return (manufacturer, model, serialNumber); } + + /// + /// A field counts as resolved when it is non-empty and not the "Unknown" fallback sentinel. + /// "Unknown" is only ever produced by this class's own fallbacks, so it is safe to treat as + /// "not yet read" for retry purposes. + /// + private static bool IsResolved(string value) + => !string.IsNullOrWhiteSpace(value) + && !string.Equals(value, Unknown, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs index cc4f03c2..12dac2bf 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs @@ -55,14 +55,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security AuthFailureTracker authFailureTracker = null, EmergencyReporter emergencyReporter = null, Func backoffDelay = null, - Func onTerminalTransportFailure = null) + Func onTerminalTransportFailure = null, + (string Manufacturer, string Model, string SerialNumber)? deviceHardware = null) { if (apiClient == null) throw new ArgumentNullException(nameof(apiClient)); if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); if (logger == null) throw new ArgumentNullException(nameof(logger)); backoffDelay ??= DefaultBackoffDelay; - var registration = BuildRegistration(agentConfig, agentVersion); + var registration = BuildRegistration(agentConfig, agentVersion, deviceHardware); string lastError = null; Exception lastException = null; @@ -142,15 +143,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Security private static Task DefaultBackoffDelay(int attempt) => Task.Delay(((int)Math.Pow(2, attempt)) * 1000); - private static SessionRegistration BuildRegistration(AgentConfiguration agentConfig, string agentVersion) + private static SessionRegistration BuildRegistration( + AgentConfiguration agentConfig, + string agentVersion, + (string Manufacturer, string Model, string SerialNumber)? deviceHardware = null) { + // Reuse the single hardened hardware read (HardwareInfo.GetHardwareInfo) that already + // populated the security headers instead of re-querying WMI here. This saves a WMI + // round-trip AND — because that read retries on transient WMI unavailability during + // OOBE — keeps the session row's Manufacturer/Model/SerialNumber consistent with the + // headers the backend validated against. Falls back to a fresh DeviceInfoProvider read + // only when no hardware was supplied (e.g. unit tests exercising the helper directly). + string manufacturer = deviceHardware?.Manufacturer ?? DeviceInfoProvider.GetManufacturer() ?? string.Empty; + string model = deviceHardware?.Model ?? DeviceInfoProvider.GetModel() ?? string.Empty; + string serialNumber = deviceHardware?.SerialNumber ?? DeviceInfoProvider.GetSerialNumber() ?? string.Empty; + return new SessionRegistration { SessionId = agentConfig.SessionId, TenantId = agentConfig.TenantId, - SerialNumber = DeviceInfoProvider.GetSerialNumber() ?? string.Empty, - Manufacturer = DeviceInfoProvider.GetManufacturer() ?? string.Empty, - Model = DeviceInfoProvider.GetModel() ?? string.Empty, + SerialNumber = serialNumber, + Manufacturer = manufacturer, + Model = model, DeviceName = Environment.MachineName, OsName = DeviceInfoProvider.GetOsName() ?? string.Empty, OsBuild = DeviceInfoProvider.GetOsBuild() ?? string.Empty, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs index 6038afac..4f45d009 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs @@ -40,7 +40,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime logger: logger, authFailureTracker: auth.AuthFailureTracker, emergencyReporter: auth.EmergencyReporter, - onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger)) + onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger), + // Reuse the single hardened hardware read from the auth bundle so the session row + // matches the security headers the backend validated against (no second WMI query). + deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber)) .GetAwaiter().GetResult(); if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)