diff --git a/docs/agent/autopilot-ztd-diagnostics.md b/docs/agent/autopilot-ztd-diagnostics.md index aac9c59e..7744b322 100644 --- a/docs/agent/autopilot-ztd-diagnostics.md +++ b/docs/agent/autopilot-ztd-diagnostics.md @@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into evidence-backed verdict, and the backend rule engine uses the error-code map for known-issue enrichment. +Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`) +`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365 +service, not by Autopilot. The event is still emitted with the full evidence payload plus +an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of +a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's +Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions +(`session.isCloudPc` or the event marker), the amber warning otherwise. + Consumers in this repo: * `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry diff --git a/docs/log.md b/docs/log.md index 152f3fd7..729bbc04 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-12 + +* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker). + ## 2026-08-11 (later) * **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs index a669670d..e0db4612 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/DeviceInfo/DeviceInfoCollectorAutopilotProfileMissingTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo; @@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain)); Assert.Null(domain); } + + [Fact] + public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment() + { + var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false); + + Assert.Equal(EventSeverity.Warning, severity); + Assert.Equal( + "No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.", + message); + } + + [Fact] + public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected() + { + // On a Windows 365 Cloud PC a missing Autopilot profile is the normal state — + // the event must not read as an alarm there. + var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true); + + Assert.Equal(EventSeverity.Info, severity); + Assert.Contains("Windows 365 Cloud PC", message); + Assert.Contains("expected", message); + Assert.DoesNotContain("no deployment profile was assigned", message); + } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index 26c4e4dd..bf73e678 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo // (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model). if (IsAutopilotProfileMissing(data)) { + // On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs + // are provisioned by the Windows 365 service, not by Autopilot — so the event + // is demoted to Info there instead of raising a misleading warning. + var isCloudPc = CloudPcDetector.DetectIsCloudPc(); + var missingData = new Dictionary { { "profileAvailable", "0" }, - { "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" } + { "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }, + { "isCloudPc", isCloudPc } }; if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain)) { @@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo missingData["ztdEndpointLatencyMs"] = probe.LatencyMs; missingData["ztdEndpointDetail"] = probe.Detail; + var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc); EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing, - "No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.", + presentation.Message, missingData, - EventSeverity.Warning); + presentation.Severity); - _logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}"); + var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}"; + if (isCloudPc) + _logger.Info(logLine); + else + _logger.Warning(logLine); } // Emit dedicated enrollment_type_detected event for easy filtering. Routed through @@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo && profileAvailable?.ToString() == "0"; } + /// + /// Message and severity for the autopilot_profile_missing event. On Windows 365 + /// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the + /// Windows 365 service, not by Autopilot), so the event is informational there; + /// on physical devices it stays a warning about a missing profile ASSIGNMENT. + /// + internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc) + { + if (isCloudPc) + { + return ( + "No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.", + EventSeverity.Info); + } + + return ( + "No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.", + EventSeverity.Warning); + } + /// /// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data. /// The value lives in the CloudAssignedAadServerData JSON — either as a top-level diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 77381bf1..979b29c8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -277,6 +277,7 @@ export const DEVICE_PROPERTIES_CATALOG = { 'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' }, 'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' }, 'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (profile_not_assigned, profile_assignment_not_propagated) — the device itself passed backend device-registration validation, so the gap is the profile assignment, not the registration' }, + 'autopilot_profile_missing.isCloudPc': { type: 'boolean', description: 'true when the device is a Windows 365 Cloud PC — there the missing profile is expected (Cloud PCs are provisioned by the Windows 365 service, not Autopilot) and the event is emitted as Info instead of Warning' }, }, hardware_spec: { 'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' }, diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx index 9971662d..8f6a15df 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx @@ -170,9 +170,14 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session const autopilotProfile = normalizeAutopilotProfile(getEventData("autopilot_profile")); // Explicit agent signal (new sessions) OR ProfileAvailable=0 in the cached profile // (also covers sessions recorded before autopilot_profile_missing existed). + const autopilotProfileMissingEvent = getEventData("autopilot_profile_missing"); const autopilotProfileMissing = - getEventData("autopilot_profile_missing") !== null || + autopilotProfileMissingEvent !== null || (autopilotProfile !== null && `${autopilotProfile.ProfileAvailable}` === "0"); + // Windows 365 Cloud PCs have no Autopilot profile by design — session flag (covers + // historic sessions, sticky-true) OR the event's own isCloudPc marker (newer agents). + const profileMissingIsCloudPc = + session?.isCloudPc === true || `${autopilotProfileMissingEvent?.isCloudPc}` === "true"; const aadJoinStatus = getEventData("aad_join_status"); const imeVersion = getEventData("ime_agent_version"); const realmJoinInfo = getEventData("realmjoin_detected"); @@ -456,7 +461,22 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session {/* Autopilot Profile */} {autopilotProfile && ( - {autopilotProfileMissing && ( + {autopilotProfileMissing && profileMissingIsCloudPc && ( +
+
+ + + + + No Autopilot profile — expected for this Cloud PC.{" "} + Windows 365 Cloud PCs are provisioned by the Windows 365 service and do + not use Autopilot deployment profiles. OOBE ran as a standard Entra ID + join, which is the normal path for Cloud PCs. + +
+
+ )} + {autopilotProfileMissing && !profileMissingIsCloudPc && (