distress: carry the W365 Cloud PC marker into Devices Not Registered

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
This commit is contained in:
Claude
2026-08-07 10:52:35 +00:00
parent 1c5f69a9fe
commit d86ae18a5e
11 changed files with 118 additions and 12 deletions
+10 -1
View File
@@ -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
+4
View File
@@ -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.
@@ -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),
@@ -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,
@@ -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<DistressReportEntry>
{
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<DistressReportEntry>
{
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)
@@ -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"),
@@ -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,
@@ -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)
@@ -30,6 +30,12 @@ namespace AutopilotMonitor.Shared.DataAccess
public DateTime IngestedAt { get; set; }
public string? SourceIp { get; set; }
/// <summary>
/// Agent-reported W365 marker verdict (CloudPcDetector). UNVERIFIED like every distress
/// field; false for rows written before the field existed.
/// </summary>
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; }
@@ -128,6 +128,15 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// True when the agent's local W365 marker check (<c>CloudPcDetector</c>: 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.
/// </summary>
public bool IsCloudPc { get; set; }
/// <summary>
/// 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 <c>Enum.IsDefined</c>.
@@ -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 && (
<>
<div className="px-6 pt-4">
<div className="px-6 pt-4 space-y-3">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
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.
</p>
</div>
{data!.aggregated.some((row) => row.isCloudPc) && (
<div className="bg-sky-50 border border-sky-200 rounded-lg p-3">
<p className="text-xs text-sky-800">
Devices marked <strong>Cloud PC</strong> identify as Windows 365 Cloud PCs. Cloud PCs are
never Autopilot-registered — enable <strong>Windows 365 Cloud PC Validation</strong> above
(and grant the <strong>W365CloudPcValidation</strong> add-on under Optional Graph
capabilities) so they can be admitted.
</p>
</div>
)}
</div>
<div className="p-6">
@@ -173,7 +185,17 @@ export default function NotRegisteredDevicesInsights({
<tr key={`${row.serialNumber}|${idx}`}>
<td className="py-3 pr-4 font-mono text-gray-900">{row.serialNumber || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.manufacturer || "–"}</td>
<td className="py-3 pr-4 text-gray-700">{row.model || "–"}</td>
<td className="py-3 pr-4 text-gray-700">
{row.model || "–"}
{row.isCloudPc && (
<span
className="ml-2 px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-sky-100 text-sky-800"
title="Windows 365 Cloud PC (agent-detected W365 markers, unverified)"
>
Cloud PC
</span>
)}
</td>
<td className="py-3 pr-4 text-right font-mono text-gray-900">{row.attemptCount}</td>
<td className="py-3 pr-4 text-gray-500 whitespace-nowrap">{formatDate(row.firstSeen)}</td>
<td className="py-3 text-gray-500 whitespace-nowrap">{formatDate(row.lastSeen)}</td>