From d86ae18a5e876625664d74f50da4cb49651cf327 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:52:35 +0000 Subject: [PATCH] distress: carry the W365 Cloud PC marker into Devices Not Registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First W365 field run: the rejected Cloud PC appeared in the report as an anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the admin that the fix is the Cloud PC validator, not an Autopilot-registration hunt. The agent's CloudPcDetector verdict (Windows365 key AND CloudManagedDesktopExtension service) now rides the pre-auth distress channel: - Agent: DistressReporter captures isCloudPc at construction (optional ctor arg, test fakes stay source-compatible); BackendClientFactory feeds it from CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the 1536-byte payload cap without a bump — comment updated) - Backend: DistressReportEntry.IsCloudPc, table column + read mapping (legacy rows -> false), structured log + AgentDistressReport AI event key; GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR semantics as the Sessions IsCloudPc merge) and the data-quality notice names the new self-reported field. GA global/distress-reports serializes the full entry and picks it up automatically. - Web: Cloud PC badge in the Devices Not Registered table (same sky badge as the session table) + conditional hint pointing at the Windows 365 Cloud PC Validation toggle and the W365CloudPcValidation Graph add-on. - Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains the optional flag. Docs: third transport rail in backend/cloudpc-device-validation.md + log entry. Unverified like every distress field — triage context only, never an auth input. Verified: web tsc clean, eslint 0 errors (file's single warning pre-exists). .NET build/tests run in CI (no SDK in this sandbox). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv --- docs/backend/cloudpc-device-validation.md | 11 ++++- docs/log.md | 4 ++ .../Monitoring/Transport/DistressReporter.cs | 9 ++++- .../Runtime/BackendClientFactory.cs | 3 +- .../GetDeviceNotRegisteredFunctionTests.cs | 40 ++++++++++++++++++- .../TableDistressReportRepository.cs | 2 + .../Ingest/ReportDistressFunction.cs | 8 +++- .../Reports/GetDeviceNotRegisteredFunction.cs | 6 ++- .../DataAccess/IDistressReportRepository.cs | 6 +++ .../Models/Diagnostics/DistressReport.cs | 9 +++++ .../NotRegisteredDevicesInsights.tsx | 32 ++++++++++++--- 11 files changed, 118 insertions(+), 12 deletions(-) diff --git a/docs/backend/cloudpc-device-validation.md b/docs/backend/cloudpc-device-validation.md index 7cca122e..5ef4c3fe 100644 --- a/docs/backend/cloudpc-device-validation.md +++ b/docs/backend/cloudpc-device-validation.md @@ -101,7 +101,7 @@ installed `CloudManagedDesktopExtension` service (probed via its `HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no `System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`. -Two independent transport rails, never an auth input: +Three independent transport rails, never an auth input: - **Session metadata** — `SessionRegistration.IsCloudPc` → Sessions table (sticky-true OR across re-registrations, like @@ -110,6 +110,15 @@ Two independent transport rails, never an auth input: MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified — can legitimately disagree with the server-derived `ValidatedBy = CloudPc` (e.g. Cloud PC admitted via a stage that ran earlier). +- **Distress channel** — `DistressReport.IsCloudPc` (captured once at + `DistressReporter` construction, `BackendClientFactory`) rides every pre-auth + distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not + Registered" report aggregates it **sticky-true** per serial + (`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC + badge plus a hint pointing at the Cloud PC validation toggle + the + `W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress + fields; rows from older agents read false. Fits the existing 1536-byte + payload cap without a bump. - **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload (`isCloudPc`), is recorded set-once for BOTH values in `EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile diff --git a/docs/log.md b/docs/log.md index a7cdbc0d..f9977fcd 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-07 (later) + +* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap. + ## 2026-08-07 * **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs index 657d1a59..ade8e0b3 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs @@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport private DateTime? _lastReportTime; private int _reportCount; + // Local W365 marker verdict, captured once at construction (same UNVERIFIED contract + // as the hardware fields). Optional ctor arg so test fakes stay source-compatible. + private readonly bool _isCloudPc; + public DistressReporter( string baseUrl, string tenantId, @@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport string serialNumber, string agentVersion, AgentLogger logger, - X509Certificate2 clientCertificate = null) + X509Certificate2 clientCertificate = null, + bool isCloudPc = false) { _tenantId = tenantId; _manufacturer = manufacturer; @@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport _serialNumber = serialNumber; _agentVersion = agentVersion; _logger = logger; + _isCloudPc = isCloudPc; // Capture cert context. Each field is independently nullable so the backend can tell // "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert @@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport HttpStatusCode = httpStatusCode, Message = Truncate(message, 256), Timestamp = DateTime.UtcNow, + IsCloudPc = _isCloudPc, CertSourceState = _capturedCertState, CertThumbprint = _capturedCertThumbprint, CertSubject = Truncate(_capturedCertSubject, 96), diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs index 1b47c028..cfa313fb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs @@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime serialNumber: hardware.SerialNumber, agentVersion: agentVersion, logger: logger, - clientCertificate: clientCertificate); + clientCertificate: clientCertificate, + isCloudPc: CloudPcDetector.DetectIsCloudPc()); var emergencyReporter = new EmergencyReporter( apiClient: backendApiClient, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs index dc7c55eb..17d942bd 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetDeviceNotRegisteredFunctionTests.cs @@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests Assert.Equal(3, (int)item.attemptCount); } + // ========================================================================= + // Cloud PC marker aggregation + // ========================================================================= + + [Fact] + public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup() + { + // One old agent (no field → false) + one new agent reporting the W365 marker: + // the aggregate must say Cloud PC even though the MOST RECENT report is false — + // same OR semantics as the Sessions-table IsCloudPc merge. + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true), + MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.True((bool)item.isCloudPc); + } + + [Fact] + public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker() + { + var reports = new List + { + MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0), + }; + + var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports); + + var item = ToDynamic(aggregated[0]); + Assert.False((bool)item.isCloudPc); + } + // ========================================================================= // Helpers // ========================================================================= private static DistressReportEntry MakeEntry( - string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) => + string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt, + bool isCloudPc = false) => new() { TenantId = "test-tenant", @@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests SerialNumber = serial, IngestedAt = ingestedAt, AgentTimestamp = ingestedAt, + IsCloudPc = isCloudPc, }; private static dynamic ToDynamic(object obj) diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs index d07f5f03..40c917bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDistressReportRepository.cs @@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage ["AgentTimestamp"] = entry.AgentTimestamp, ["IngestedAt"] = entry.IngestedAt, ["SourceIp"] = Truncate(entry.SourceIp, 45), // max IPv6 length + ["IsCloudPc"] = entry.IsCloudPc, ["CertSourceState"] = Truncate(entry.CertSourceState, 32), ["CertThumbprint"] = Truncate(entry.CertThumbprint, 40), ["CertSubject"] = Truncate(entry.CertSubject, 96), @@ -125,6 +126,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage AgentTimestamp = entity.GetDateTimeOffset("AgentTimestamp")?.UtcDateTime ?? DateTime.MinValue, IngestedAt = entity.GetDateTimeOffset("IngestedAt")?.UtcDateTime ?? DateTime.MinValue, SourceIp = entity.GetString("SourceIp"), + IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false, CertSourceState = entity.GetString("CertSourceState"), CertThumbprint = entity.GetString("CertThumbprint"), CertSubject = entity.GetString("CertSubject"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs index 5368e662..efd75066 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs @@ -44,6 +44,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Strict limits for the unauthenticated endpoint. // MaxContentLength bumped from 1024 -> 1536 to accommodate the V2 cert-context fields // (Thumbprint + Subject + Issuer + NotBefore/After + SourceState ~430 bytes worst-case). + // The IsCloudPc bool (2026-08) adds ≤ 18 bytes ("\"IsCloudPc\":false,") — well within + // the existing headroom, no bump needed. // Rate-limiting (per-IP + per-tenant + global circuit breaker) remains the primary // DoS defense — the size cap is a payload-shape gate, not the throttle. internal const int MaxContentLength = 1536; @@ -204,6 +206,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest AgentTimestamp = report.Timestamp, IngestedAt = DateTime.UtcNow, SourceIp = clientIp, + IsCloudPc = report.IsCloudPc, CertSourceState = certSourceState, CertThumbprint = certThumbprint, CertSubject = certSubject, @@ -297,8 +300,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Structured log (Warning, not Critical — data is unverified) _logger.LogWarning( - "AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}", - report.ErrorType, tenantId, manufacturer, model, serialNumber, + "AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}", + report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc, report.HttpStatusCode, agentVersion, certSourceState ?? "n/a", certThumbprint ?? "n/a", message); // Custom event for KQL queries: @@ -315,6 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest ["Message"] = message ?? string.Empty, ["AgentTimestamp"] = report.Timestamp.ToString("O"), ["SourceIp"] = clientIp, + ["IsCloudPc"] = report.IsCloudPc.ToString(), ["CertSourceState"] = certSourceState ?? string.Empty, ["CertThumbprint"] = certThumbprint ?? string.Empty, ["CertSubject"] = certSubject ?? string.Empty, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs index 1f05c468..708f4fc0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetDeviceNotRegisteredFunction.cs @@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports success = true, aggregated, totalRawReports, - dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices." + dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices." }); } catch (Exception ex) @@ -81,6 +81,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports serialNumber = mostRecent.SerialNumber ?? "", manufacturer = mostRecent.Manufacturer ?? "", model = mostRecent.Model ?? "", + // Sticky-true across the group (same OR semantics as the Sessions-table + // IsCloudPc merge): older agents in the mix report false for the same + // device, and a once-seen W365 marker must not flap back to false. + isCloudPc = g.Any(r => r.IsCloudPc), attemptCount = g.Count(), firstSeen = g.Min(r => r.IngestedAt), lastSeen = g.Max(r => r.IngestedAt) diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDistressReportRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDistressReportRepository.cs index fb98eee0..f4d25b6a 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDistressReportRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDistressReportRepository.cs @@ -30,6 +30,12 @@ namespace AutopilotMonitor.Shared.DataAccess public DateTime IngestedAt { get; set; } public string? SourceIp { get; set; } + /// + /// Agent-reported W365 marker verdict (CloudPcDetector). UNVERIFIED like every distress + /// field; false for rows written before the field existed. + /// + public bool IsCloudPc { get; set; } + // Cert-context fields (V2 agents only). All optional; legacy entries leave these null. // Format-validated and length-capped at ingest; treat as UNVERIFIED claims. public string? CertSourceState { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DistressReport.cs b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DistressReport.cs index 5b6172e0..6af20b44 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DistressReport.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DistressReport.cs @@ -128,6 +128,15 @@ namespace AutopilotMonitor.Shared.Models /// public DateTime Timestamp { get; set; } = DateTime.UtcNow; + /// + /// True when the agent's local W365 marker check (CloudPcDetector: Windows365 + /// registry key AND CloudManagedDesktopExtension service) identified the device as a + /// Windows 365 Cloud PC. Unverified like all distress fields — pure triage context: + /// a rejected Cloud PC points the admin at the Cloud PC validation toggle instead of + /// a futile Autopilot-registration hunt. False for older agents that predate the field. + /// + public bool IsCloudPc { get; set; } + /// /// Agent's classification of the cert state at the time of the failing call. V2 agents only. /// Optional; left null/Unknown by V1 agents. Validated server-side with Enum.IsDefined. diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx index d4d35760..fc95166e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/NotRegisteredDevicesInsights.tsx @@ -8,6 +8,8 @@ interface AggregatedNotRegistered { serialNumber: string; manufacturer: string; model: string; + /** Agent-reported W365 marker verdict (unverified, like all distress fields). Absent on older backends. */ + isCloudPc?: boolean; attemptCount: number; firstSeen: string; lastSeen: string; @@ -145,14 +147,24 @@ export default function NotRegisteredDevicesInsights({ {/* Data quality notice + table (only when devices exist) */} {hasDevices && ( <> -
+

- This data comes from pre-authentication distress signals. Serial number, manufacturer, and - model values are self-reported and unverified. Use it to sanity-check whether your enrollment - scoping/assignment covers these devices. + This data comes from pre-authentication distress signals. Serial number, manufacturer, + model, and the Cloud PC marker are self-reported and unverified. Use it to sanity-check + whether your enrollment scoping/assignment covers these devices.

+ {data!.aggregated.some((row) => row.isCloudPc) && ( +
+

+ Devices marked Cloud PC identify as Windows 365 Cloud PCs. Cloud PCs are + never Autopilot-registered — enable Windows 365 Cloud PC Validation above + (and grant the W365CloudPcValidation add-on under Optional Graph + capabilities) so they can be admitted. +

+
+ )}
@@ -173,7 +185,17 @@ export default function NotRegisteredDevicesInsights({ {row.serialNumber || "–"} {row.manufacturer || "–"} - {row.model || "–"} + + {row.model || "–"} + {row.isCloudPc && ( + + Cloud PC + + )} + {row.attemptCount} {formatDate(row.firstSeen)} {formatDate(row.lastSeen)}