mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ffcb2fde2 | |||
| a3089753d3 | |||
| 26cbc897c5 | |||
| c88629dcf2 | |||
| 517041a51c | |||
| eeaeb9ea7e | |||
| 3983402983 | |||
| 4deb5e6a04 | |||
| 5734eb576e | |||
| 7fbfea8f4e | |||
| e5f955354d | |||
| 65651dd599 |
+1
-1
Submodule .claude/commands updated: 4a98eb1da6...282e90206a
@@ -5,7 +5,7 @@
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -51,7 +51,7 @@
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
|
||||
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
@@ -70,9 +70,23 @@
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Address VPN and Windows Hello dependencies",
|
||||
"steps": [
|
||||
"If a VPN is required to reach a domain controller, it must be established before or at the logon screen (machine tunnel / pre-logon tunnel). A user-context tunnel that starts after sign-in is too late for the hybrid PRT flow.",
|
||||
"If you deploy Windows Hello for Business to hybrid-joined devices, use cloud Kerberos trust — it removes the on-premises certificate/key-trust dependencies that commonly stall Hello on hybrid devices.",
|
||||
"If you do not use Windows Hello, disable it explicitly via a device-scoped Intune policy instead of leaving it unconfigured — the enrollment then resolves the Hello step deterministically instead of waiting for a wizard that never comes.",
|
||||
"Avoid feature/quality updates mid-enrollment where possible — an OS update with forced reboots during enrollment aggravates this pattern. Consider a short Windows Update deferral covering the enrollment window.",
|
||||
"For hybrid deployments, pre-provisioning (White Glove) materially shrinks the user-facing phase: the device phase runs on a known-good technician network, and the user phase reduces to sign-in, user ESP, and Hello."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — stuck in Account Setup (no user affinity)",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-stuck-in-account-setup"
|
||||
},
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
|
||||
+16
-2
@@ -2526,7 +2526,7 @@
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -2587,7 +2587,7 @@
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
@@ -2606,9 +2606,23 @@
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Address VPN and Windows Hello dependencies",
|
||||
"steps": [
|
||||
"If a VPN is required to reach a domain controller, it must be established before or at the logon screen (machine tunnel / pre-logon tunnel). A user-context tunnel that starts after sign-in is too late for the hybrid PRT flow.",
|
||||
"If you deploy Windows Hello for Business to hybrid-joined devices, use cloud Kerberos trust — it removes the on-premises certificate/key-trust dependencies that commonly stall Hello on hybrid devices.",
|
||||
"If you do not use Windows Hello, disable it explicitly via a device-scoped Intune policy instead of leaving it unconfigured — the enrollment then resolves the Hello step deterministically instead of waiting for a wizard that never comes.",
|
||||
"Avoid feature/quality updates mid-enrollment where possible — an OS update with forced reboots during enrollment aggravates this pattern. Consider a short Windows Update deferral covering the enrollment window.",
|
||||
"For hybrid deployments, pre-provisioning (White Glove) materially shrinks the user-facing phase: the device phase runs on a known-good technician network, and the user phase reduces to sign-in, user ESP, and Hello."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — stuck in Account Setup (no user affinity)",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-stuck-in-account-setup"
|
||||
},
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
|
||||
+100
-32
@@ -4,12 +4,23 @@
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": {
|
||||
"scriptType": "remediation"
|
||||
},
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -139,6 +150,14 @@
|
||||
"description": "Captures the install attempt number for the current app. IME logs 'Execute retry 0' for the first attempt and increments on each retry. Reported as attempt+1 so retry 0 becomes AttemptNumber=1 (human-friendly: first attempt).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DECRYPT-FAILED",
|
||||
"category": "currentPhase",
|
||||
@@ -168,15 +187,15 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -217,9 +236,9 @@
|
||||
{
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -266,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -293,6 +312,14 @@
|
||||
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-ESP-PHASE",
|
||||
"category": "always",
|
||||
@@ -304,9 +331,9 @@
|
||||
{
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -320,6 +347,17 @@
|
||||
"description": "Captures the installer exit code (lpExitCode) for the current app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-HRESULT",
|
||||
"category": "currentPhase",
|
||||
@@ -422,6 +460,17 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SESSION-CHANGE",
|
||||
"category": "always",
|
||||
@@ -435,24 +484,24 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
|
||||
"enabled": true
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-2",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
|
||||
"enabled": true
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-3",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current.",
|
||||
"enabled": true
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SET-CURRENT-4",
|
||||
@@ -478,24 +527,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
|
||||
"enabled": true
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-DETECTED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SKIP-FILTERS",
|
||||
@@ -529,8 +578,8 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STARTED",
|
||||
@@ -540,6 +589,17 @@
|
||||
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": {
|
||||
"useCurrentApp": "true"
|
||||
},
|
||||
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-SUBGRAPH",
|
||||
"category": "currentPhase",
|
||||
@@ -564,24 +624,24 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-2",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-UPDATE-NAME-3",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"patternId": "IME-USER-SESSION-COMPLETED",
|
||||
@@ -607,6 +667,14 @@
|
||||
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-WINGET-INSTALLING-1",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE-POST",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "HS-COMPLIANCE",
|
||||
"category": "always",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
|
||||
"action": "healthScriptDetectionResult",
|
||||
"parameters": { "scriptType": "remediation" },
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
|
||||
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-CACHE-MISS",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
|
||||
"action": "updateName",
|
||||
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID} ",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app is detected as installed (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-DETECTION-RESULT",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
|
||||
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ENFORCEMENT-SUCCESS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"\\w+\"\"|null),\"\"NewValue\"\":\"\"Success\"\"",
|
||||
"action": "updateStateInstalled",
|
||||
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
|
||||
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":(?:\"\"(?<from>\\w+)\"\"|null),\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "checkTo": "true" },
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-APP-REGISTERED",
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ESP-TRACK-STATUS",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
|
||||
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
|
||||
"action": "espTrackStatus",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
|
||||
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-EXITCODE-MAPPED-FAILED",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
|
||||
"action": "updateStateError",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
|
||||
"enabled": true
|
||||
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
|
||||
"enabled": true
|
||||
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
|
||||
"action": "setCurrentApp",
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current.",
|
||||
"enabled": true
|
||||
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
|
||||
"enabled": true
|
||||
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
|
||||
"action": "updateStateSkipped",
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
|
||||
"enabled": true
|
||||
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-STOP-SIGNAL",
|
||||
"category": "currentPhase",
|
||||
"pattern": "EMS Agent received stop signal",
|
||||
"action": "updateStatePostponed",
|
||||
"parameters": { "useCurrentApp": "true" },
|
||||
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
|
||||
"action": "updateName",
|
||||
"description": "Captures the app name from the Processing app log entry.",
|
||||
"enabled": true
|
||||
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-WINGET-ERROR",
|
||||
"category": "currentPhase",
|
||||
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
|
||||
"action": "updateStateError",
|
||||
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
|
||||
"enabled": true
|
||||
}
|
||||
+111
-29
@@ -525,40 +525,69 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
continue;
|
||||
}
|
||||
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
// Active event-log channels hold their .evtx exclusively locked — a raw
|
||||
// FileStream open fails with a sharing violation. Export the channel via
|
||||
// wevtutil instead and stream the export (deleted below). Session a11102f4:
|
||||
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
|
||||
string streamSource = file;
|
||||
string tempExport = null;
|
||||
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
continue;
|
||||
tempExport = TryExportEventLogChannel(file);
|
||||
if (tempExport == null)
|
||||
{
|
||||
tracker.RecordSkip(file, "evtx-export", length);
|
||||
continue;
|
||||
}
|
||||
streamSource = tempExport;
|
||||
try { length = new FileInfo(tempExport).Length; }
|
||||
catch { /* keep original length estimate */ }
|
||||
}
|
||||
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
|
||||
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
|
||||
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var entryStream = entry.Open())
|
||||
try
|
||||
{
|
||||
fs.CopyTo(entryStream);
|
||||
}
|
||||
if (length > tracker.Budget.MaxSingleFileBytes)
|
||||
{
|
||||
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
|
||||
tracker.RecordSkip(file, "size", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedCount())
|
||||
{
|
||||
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
|
||||
tracker.RecordSkip(file, "count", length);
|
||||
continue;
|
||||
}
|
||||
if (tracker.WouldExceedTotal(length))
|
||||
{
|
||||
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
|
||||
tracker.RecordSkip(file, "total", length);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
|
||||
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
|
||||
using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
fs.CopyTo(entryStream);
|
||||
}
|
||||
|
||||
tracker.RecordIncluded(length);
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempExport != null)
|
||||
{
|
||||
try { File.Delete(tempExport); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -567,6 +596,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
|
||||
/// <c>wevtutil epl</c>. Channel name is derived from the file name (%4 decodes to '/',
|
||||
/// e.g. "Microsoft-Autopilot-BootstrapperAgent%4BootstrapperAgentServiceLogProvider.evtx"
|
||||
/// → "Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider").
|
||||
/// Returns the temp file path (caller deletes) or null when the export failed.
|
||||
/// </summary>
|
||||
private string TryExportEventLogChannel(string evtxPath)
|
||||
{
|
||||
string tempPath = null;
|
||||
try
|
||||
{
|
||||
var channel = Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
|
||||
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(Environment.SystemDirectory, "wevtutil.exe"),
|
||||
Arguments = $"epl \"{channel}\" \"{tempPath}\" /ow:true",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
using (var process = System.Diagnostics.Process.Start(psi))
|
||||
{
|
||||
if (process == null) return null;
|
||||
if (!process.WaitForExit(30000))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Warning($"wevtutil export timed out for channel '{channel}'.");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
_logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}");
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return File.Exists(tempPath) ? tempPath : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Event log export failed for {evtxPath}: {ex.Message}");
|
||||
if (tempPath != null)
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteTruncatedMarker(ZipArchive archive, BudgetTracker tracker)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
+6
-20
@@ -156,7 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
/// </summary>
|
||||
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
|
||||
{
|
||||
var detectedType = "v1";
|
||||
// Single source of truth for the rail classification — the SAME detector the
|
||||
// session registration uses (incl. the WDP DevicePreparation subkey probe). The
|
||||
// previous inline CloudAssigned*-only re-implementation kept reporting "v1" on
|
||||
// Device Preparation devices while the session row already said "v2" (a11102f4).
|
||||
var (detectedType, detectionRule) = AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
|
||||
var isHybridJoin = false;
|
||||
int? detectedAutopilotMode = null;
|
||||
|
||||
@@ -188,20 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -377,11 +367,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{ "enrollmentType", detectedType },
|
||||
{ "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" },
|
||||
{ "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" },
|
||||
{ "detectionRule", detectedType == "v2"
|
||||
? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2"
|
||||
? "CloudAssignedDeviceRegistration=2"
|
||||
: "CloudAssignedEspEnabled=0")
|
||||
: "default (no v2 indicators)" }
|
||||
{ "detectionRule", detectionRule }
|
||||
});
|
||||
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
|
||||
|
||||
@@ -170,33 +170,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <summary>
|
||||
/// Classifies the Autopilot flow based on the <c>AutopilotSettings</c> registry:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CloudAssignedDeviceRegistration == 2</c> → <c>"v2"</c> (Windows Device Preparation).</item>
|
||||
/// <item><c>CloudAssignedEspEnabled == 0</c> → <c>"v2"</c> (no ESP, WDP indicator).</item>
|
||||
/// <item><c>DevicePreparation\BootstrapperAgent</c> SUBKEY with a non-empty
|
||||
/// <c>ExecutionContext</c> → <c>"v2"</c>. This is the Device Preparation policy
|
||||
/// orchestration tree (ExecutionContext JSON carries the WDP policyId and the
|
||||
/// provider batch list). Field-verified 2026-08-17: present on a WDP device,
|
||||
/// absent on classic v1 — which only has ESP category VALUES named
|
||||
/// <c>DevicePreparation.*</c> on the AutopilotSettings key itself, hence the
|
||||
/// subkey probe, never a value-name probe.</item>
|
||||
/// <item><c>CloudAssignedDeviceRegistration == 2</c> → <c>"v2"</c> (legacy signal).</item>
|
||||
/// <item><c>CloudAssignedEspEnabled == 0</c> → <c>"v2"</c> (no ESP, legacy signal).
|
||||
/// Both CloudAssigned* values are profile-derived and structurally ABSENT on WDP
|
||||
/// (no Autopilot profile), so they never fire there — kept for defence in depth.</item>
|
||||
/// <item>Anything else → <c>"v1"</c> (Classic Autopilot / ESP).</item>
|
||||
/// </list>
|
||||
/// Defaults to <c>"v1"</c> on any error so a flaky registry does not misclassify a
|
||||
/// classic session as WDP.
|
||||
/// </summary>
|
||||
public static string DetectEnrollmentType()
|
||||
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
|
||||
/// enrollment_type_detected event can name its evidence instead of re-implementing the
|
||||
/// detection (the duplicate inline logic in DeviceInfoCollector missed the WDP subkey
|
||||
/// rule and kept reporting "v1" on Device Preparation devices — session a11102f4).
|
||||
/// </summary>
|
||||
public static (string enrollmentType, string detectionRule) DetectEnrollmentTypeWithRule()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var wdpKey = Registry.LocalMachine.OpenSubKey(
|
||||
AutopilotSettingsKey + @"\DevicePreparation\BootstrapperAgent"))
|
||||
{
|
||||
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
|
||||
if (!string.IsNullOrEmpty(executionContext))
|
||||
return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present");
|
||||
}
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
|
||||
{
|
||||
if (key == null) return "v1";
|
||||
if (key == null) return ("v1", "AutopilotSettings key missing");
|
||||
|
||||
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
|
||||
if (deviceReg == "2") return "v2";
|
||||
if (deviceReg == "2") return ("v2", "CloudAssignedDeviceRegistration=2");
|
||||
|
||||
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
|
||||
if (espEnabled == "0") return "v2";
|
||||
if (espEnabled == "0") return ("v2", "CloudAssignedEspEnabled=0");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to v1 default.
|
||||
}
|
||||
return "v1";
|
||||
return ("v1", "default (no v2 indicators)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
|
||||
#
|
||||
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
|
||||
# script/app batches during Autopilot Device Preparation). This runner downloads the
|
||||
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
|
||||
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
|
||||
# server-updatable without re-shipping the MSI.
|
||||
#
|
||||
# Fail-soft by design: the runner always exits 0 so a transient network failure never
|
||||
# marks the LOB install as failed; diagnostics go to msi-bootstrap.log.
|
||||
# ASCII only - no special characters in this file.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
|
||||
$logDir = Join-Path $baseDir 'Logs'
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir 'msi-bootstrap.log'
|
||||
|
||||
function Write-Log([string]$message) {
|
||||
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
|
||||
|
||||
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
|
||||
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
|
||||
break
|
||||
} catch {
|
||||
Write-Log "Download attempt $attempt failed: $_"
|
||||
if ($attempt -eq 3) { throw }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
& $dest *>> $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<!--
|
||||
Autopilot Monitor Bootstrap MSI - thin MDM LOB delivery vehicle for WDP (Autopilot v2).
|
||||
|
||||
LOB MSIs are installed by the OMA-DM stack (EnterpriseDesktopAppManagement CSP) during
|
||||
the first MDM sync, i.e. BEFORE/parallel to the Device Preparation SLDM app batch -
|
||||
earlier than any IME-delivered script (Batch 2) or Win32 app can run. The MSI itself
|
||||
carries no bootstrap logic: it installs one runner script and executes it once. The
|
||||
runner downloads the CURRENT server-hosted Install-AutopilotMonitor.ps1, keeping all
|
||||
real logic updatable without re-shipping the MSI.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.0.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
|
||||
<Directory Id="INSTALLFOLDER" Name="Msi" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<Component Id="RunnerScript" Guid="8c8e8962-719f-458b-b291-dd99d3f1dc30">
|
||||
<File Id="RunnerPs1" Source="Invoke-BootstrapDownload.ps1" KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
<Feature Id="Main" Level="1">
|
||||
<ComponentGroupRef Id="MainComponents" />
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the runner hits a transient error (the runner is
|
||||
additionally fail-soft itself); diagnostics live in msi-bootstrap.log. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Invoke-BootstrapDownload.ps1""
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="RunBootstrap" After="InstallFiles" Condition="NOT Installed" />
|
||||
</InstallExecuteSequence>
|
||||
</Package>
|
||||
</Wix>
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Graph contract tests for <see cref="CorporateIdentifierValidator"/>. Field case 2026-08-17
|
||||
/// (first live Device Preparation enrollment): the searchExistingIdentities ACTION requires
|
||||
/// DeviceManagementServiceConfig.ReadWrite.All, which the app does not hold, so validation
|
||||
/// 403'd in every tenant — and the transient classification trapped the agent in an endless
|
||||
/// 503 Retry-After loop. The validator now READS importedDeviceIdentities (Read.All suffices),
|
||||
/// matches only manufacturerModelSerial identities exactly (client-side, case-insensitive),
|
||||
/// and classifies Graph 401/403 as a definitive, actionable failure.
|
||||
/// </summary>
|
||||
public class CorporateIdentifierValidatorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Serial = "7801-5131-4473-3387-5637-4002-18";
|
||||
private const string MmsIdentifier = $"Microsoft Corporation,Virtual Machine,{Serial}";
|
||||
// The form Intune actually stores (field-verified 2026-08-17 in the portal list):
|
||||
// uppercased, spaces and dashes stripped per component.
|
||||
private const string StoredIdentifier = "MICROSOFTCORPORATION,VIRTUALMACHINE,78015131447333875637400218";
|
||||
private const string StoredSerial = "78015131447333875637400218";
|
||||
|
||||
private sealed class SequenceHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses;
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
|
||||
public SequenceHandler(params HttpResponseMessage[] responses) => _responses = new Queue<HttpResponseMessage>(responses);
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(request);
|
||||
// Last response is sticky so retry loops in the SUT cannot dequeue past the script.
|
||||
var response = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) =>
|
||||
new(status) { Content = new StringContent(body) };
|
||||
|
||||
private static (CorporateIdentifierValidator Sut, SequenceHandler Handler) BuildSut(params HttpResponseMessage[] responses)
|
||||
{
|
||||
var handler = new SequenceHandler(responses);
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handler));
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
var registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
var sut = new CorporateIdentifierValidator(
|
||||
NullLogger<CorporateIdentifierValidator>.Instance, factory.Object, cache, tokenService.Object);
|
||||
return (sut, handler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_usesGet_withContainsFilterOnNormalizedSerial()
|
||||
{
|
||||
var (sut, handler) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
var request = Assert.Single(handler.Requests);
|
||||
Assert.Equal(HttpMethod.Get, request.Method);
|
||||
var url = request.RequestUri!.ToString();
|
||||
Assert.Contains("/beta/deviceManagement/importedDeviceIdentities", url);
|
||||
Assert.DoesNotContain("searchExistingIdentities", url);
|
||||
// The stored value is normalized (dashless), so filtering with the raw dashed serial
|
||||
// could never hit — the filter must use the normalized serial.
|
||||
Assert.Contains($"contains(importedDeviceIdentifier,'{StoredSerial}')", Uri.UnescapeDataString(url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_storedNormalizedIdentity_matchesRawDeviceValues()
|
||||
{
|
||||
// THE field case: device reports "Microsoft Corporation" / "Virtual Machine" / dashed
|
||||
// serial; Intune stores "MICROSOFTCORPORATION,VIRTUALMACHINE,<dashless>". Must match.
|
||||
var (sut, _) = BuildSut(Json($$"""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(MmsIdentifier, result.Identifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_rawStoredIdentity_alsoMatches()
|
||||
{
|
||||
// Entries created directly via Graph may be stored raw — both sides are normalized
|
||||
// before comparison, so these match too.
|
||||
var (sut, _) = BuildSut(Json($$"""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier}}"}]}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_differentDevice_doesNotMatch()
|
||||
{
|
||||
var (sut, _) = BuildSut(Json("""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,39260126318839370830655125"}]}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_serialOnlyIdentity_doesNotAuthorize()
|
||||
{
|
||||
// Serial-type corporate identifiers are an Intune-side misconfiguration for Windows,
|
||||
// deliberately not treated as authorization (decision 2026-08-17).
|
||||
var (sut, _) = BuildSut(Json($$"""
|
||||
{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"{{StoredSerial}}"}]}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_followsNextLink_untilMatch()
|
||||
{
|
||||
var (sut, handler) = BuildSut(
|
||||
Json("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"DELL,XPS,OTHER1"}],"@odata.nextLink":"https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities?$skiptoken=abc"}"""),
|
||||
Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Contains("skiptoken", handler.Requests[1].RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_noMatch_isDefinitive_andShowsBothForms()
|
||||
{
|
||||
var (sut, _) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
// Both the raw and the normalized form must appear so a rejection log is diagnosable
|
||||
// against the portal list (which shows only the normalized form).
|
||||
Assert.Contains(MmsIdentifier, result.ErrorMessage);
|
||||
Assert.Contains(StoredIdentifier, result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_filterRejected_fallsBackToUnfilteredScan()
|
||||
{
|
||||
// Some Intune endpoints reject $filter with 400 — the validator must then page the
|
||||
// unfiltered list instead of failing.
|
||||
var (sut, handler) = BuildSut(
|
||||
Json("""{"error":{"code":"BadRequest","message":"filter not supported"}}""", HttpStatusCode.BadRequest),
|
||||
Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.Contains("$filter", handler.Requests[0].RequestUri!.ToString());
|
||||
Assert.DoesNotContain("$filter", handler.Requests[1].RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Microsoft Corporation", "MICROSOFTCORPORATION")]
|
||||
[InlineData("Virtual Machine", "VIRTUALMACHINE")]
|
||||
[InlineData("7801-5131-4473-3387-5637-4002-18", "78015131447333875637400218")]
|
||||
[InlineData("ThinkPad T14 Gen 3", "THINKPADT14GEN3")]
|
||||
[InlineData("Serial.With.Periods", "SERIALWITHPERIODS")]
|
||||
public void NormalizeComponent_matchesIntunePortalNormalization(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CorporateIdentifierValidator.NormalizeComponent(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Forbidden)]
|
||||
[InlineData(HttpStatusCode.Unauthorized)]
|
||||
public async Task ValidateAsync_graphPermissionDenied_isDefinitive_withActionableMessage(HttpStatusCode status)
|
||||
{
|
||||
var (sut, _) = BuildSut(Json("""{"error":{"code":"Forbidden","message":"Application is not authorized"}}""", status));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Contains("DeviceManagementServiceConfig.Read.All", result.ErrorMessage);
|
||||
Assert.Contains("consent", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_graphPermissionDenied_isCached_notRetried()
|
||||
{
|
||||
// Definitive permission failures are negative-cached so an agent retry storm does not
|
||||
// hammer Graph while consent is being fixed; the in-call retry loop must not fire either.
|
||||
var (sut, handler) = BuildSut(Json("""{"error":{"code":"Forbidden"}}""", HttpStatusCode.Forbidden));
|
||||
|
||||
await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
var second = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Single(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_graphServerError_isTransient()
|
||||
{
|
||||
var (sut, _) = BuildSut(Json("""{"error":{"code":"InternalServerError"}}""", HttpStatusCode.InternalServerError));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
}
|
||||
}
|
||||
@@ -141,4 +141,28 @@ public class SecurityValidatorTests
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
|
||||
}
|
||||
|
||||
// --- Device-validation error aggregation ---
|
||||
// With several validators enabled, the 403 Details used to carry only the LAST failure
|
||||
// (CloudPc), hiding why the Autopilot / CorporateIdentifier lookups missed — the exact
|
||||
// trap during the first live WDP enrollment (2026-08-17).
|
||||
|
||||
[Fact]
|
||||
public void CombineValidationErrors_joinsInChainOrder()
|
||||
{
|
||||
var combined = SecurityValidator.CombineValidationErrors("autopilot miss", "corp miss");
|
||||
combined = SecurityValidator.CombineValidationErrors(combined, "cloudpc miss");
|
||||
|
||||
Assert.Equal("autopilot miss | corp miss | cloudpc miss", combined);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "only", "only")]
|
||||
[InlineData("only", null, "only")]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("", "only", "only")]
|
||||
public void CombineValidationErrors_handlesMissingHalves(string? a, string? b, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SecurityValidator.CombineValidationErrors(a, b));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
/// NCRONTAB format: {second} {minute} {hour} {day} {month} {day-of-week}
|
||||
/// Reduced from daily (2:00 UTC) to every 2 hours to catch stalled sessions faster.
|
||||
/// MarkStalledSessionsAsTimedOutAsync is idempotent (terminal states not overwritten).
|
||||
/// The stalled-session sweep additionally runs hourly at minute 30 via the dedicated
|
||||
/// SessionSweepFunction interleave — this chain keeps it as its first step regardless.
|
||||
/// </summary>
|
||||
[Function("Maintenance")]
|
||||
public async Task Run([TimerTrigger("0 0 */2 * * *")] object timer)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Maintenance
|
||||
{
|
||||
/// <summary>
|
||||
/// Hourly interleave for the stalled-session sweep
|
||||
/// (<see cref="MaintenanceService.MarkStalledSessionsAsTimedOutAsync"/>). The 2h
|
||||
/// <c>Maintenance</c> timer keeps running the sweep as its first step; this function adds an
|
||||
/// offset tick at minute 30, so between the two the sweep effectively runs every hour and the
|
||||
/// sweep-induced classification latency drops from ≤2h to ≤1h (a 5h-silent session is
|
||||
/// terminalized after ~6h worst case instead of ~7h). Thresholds (2h silence → Stalled,
|
||||
/// SessionTimeoutHours → terminal) are unchanged — only the sampling rate rises.
|
||||
/// <para>
|
||||
/// No lease/serialization on purpose (unlike <see cref="SessionDeletionMaintenanceFunction"/>):
|
||||
/// the sweep is idempotent — terminal states are never overwritten, the silence guard protects
|
||||
/// live sessions, and a doubled pass is a no-op — so overlapping with the 2h run is harmless.
|
||||
/// Extraction pattern per the cadence-split precedent documented in
|
||||
/// <c>MaintenanceService.RunAllAsync</c> (SessionDeletionMaintenanceFunction).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class SessionSweepFunction
|
||||
{
|
||||
// Minute 30 — deliberately offset from the 2h Maintenance timer (minute 0) so the two
|
||||
// never fire together. const per repo convention (see IndexReconcileTimer.cs).
|
||||
private const string Cron = "0 30 * * * *";
|
||||
|
||||
private readonly MaintenanceService _maintenanceService;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
private readonly ILogger<SessionSweepFunction> _logger;
|
||||
|
||||
public SessionSweepFunction(
|
||||
MaintenanceService maintenanceService,
|
||||
OpsEventService opsEventService,
|
||||
ILogger<SessionSweepFunction> logger)
|
||||
{
|
||||
_maintenanceService = maintenanceService;
|
||||
_opsEventService = opsEventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[Function("SessionSweep")]
|
||||
public async Task Run([TimerTrigger(Cron)] object timer)
|
||||
{
|
||||
_logger.LogInformation("SessionSweep timer trigger fired");
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var result = await _maintenanceService.MarkStalledSessionsAsTimedOutAsync();
|
||||
sw.Stop();
|
||||
|
||||
if (result.Error is null)
|
||||
{
|
||||
await _opsEventService.RecordSessionSweepCompletedAsync(
|
||||
result.StalledMarked, result.TimedOut, (int)sw.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _opsEventService.RecordSessionSweepFailedAsync(result.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,15 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates devices against Intune Corporate Device Identifiers via Microsoft Graph beta API.
|
||||
/// Uses the importedDeviceIdentities/searchExistingIdentities endpoint with manufacturerModelSerial type.
|
||||
/// Reads /deviceManagement/importedDeviceIdentities (GET, DeviceManagementServiceConfig.Read.All
|
||||
/// suffices) instead of the searchExistingIdentities action, which would require the heavy
|
||||
/// ReadWrite.All scope the app deliberately does not hold (field case 2026-08-17: the action
|
||||
/// 403'd in every tenant). Only the manufacturerModelSerial type counts — the sole
|
||||
/// corporate-identifier type Windows supports; serial-only entries are an Intune-side admin
|
||||
/// misconfiguration and must not authorize. Matching happens in Intune's NORMALIZED space
|
||||
/// (uppercase, non-alphanumerics stripped per component — see <see cref="NormalizeComponent"/>),
|
||||
/// because that is the form the portal stores and matches at enrollment. A Graph 401/403
|
||||
/// means missing admin consent and is classified as a definitive failure, not transient.
|
||||
/// Caches positive/negative lookups to reduce Graph traffic.
|
||||
/// </summary>
|
||||
public class CorporateIdentifierValidator
|
||||
@@ -114,70 +122,83 @@ namespace AutopilotMonitor.Functions.Security
|
||||
var graphClient = _httpClientFactory.CreateClient();
|
||||
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
|
||||
|
||||
// POST https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities
|
||||
var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}";
|
||||
var requestBody = new
|
||||
{
|
||||
importedDeviceIdentities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
importedDeviceIdentityType = "manufacturerModelSerial",
|
||||
importedDeviceIdentifier = identifier
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonConvert.SerializeObject(requestBody),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var graphUrl = Constants.GraphBaseUrl + "/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
|
||||
var response = await graphClient.PostAsync(graphUrl, content);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
// GET /beta/deviceManagement/importedDeviceIdentities — deliberately NOT the
|
||||
// searchExistingIdentities action: the action requires the heavy
|
||||
// DeviceManagementServiceConfig.ReadWrite.All scope, while listing works with
|
||||
// Read.All, which the app already holds for windowsAutopilotDeviceIdentities.
|
||||
// Mirrors AutopilotDeviceValidator: contains() for server-side narrowing (eq is
|
||||
// unreliable on Intune endpoints), exact match client-side.
|
||||
//
|
||||
// Intune NORMALIZES corporate identifiers on upload: uppercased, every
|
||||
// non-alphanumeric character stripped per component — "Microsoft Corporation,
|
||||
// Virtual Machine,7801-5131-…" is stored as "MICROSOFTCORPORATION,
|
||||
// VIRTUALMACHINE,78015131…" (field-verified 2026-08-17: the portal list shows
|
||||
// the normalized form and the device matched + enrolled against it). All
|
||||
// matching therefore happens in normalized space — including the contains()
|
||||
// narrowing, which with the raw dashed serial would never hit the stored value.
|
||||
var rawIdentifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}";
|
||||
var normalizedIdentifier = BuildNormalizedIdentifier(normalizedManufacturer, normalizedModel, normalizedSerial);
|
||||
// Normalized serial is alphanumeric-only, so no OData quote-escaping is needed.
|
||||
var filter = Uri.EscapeDataString($"contains(importedDeviceIdentifier,'{NormalizeComponent(normalizedSerial)}')");
|
||||
var filteredUrl = $"{Constants.GraphBaseUrl}/beta/deviceManagement/importedDeviceIdentities?$top=100&$filter={filter}";
|
||||
|
||||
var scan = await ScanPagesForIdentifierAsync(graphClient, filteredUrl, normalizedIdentifier, tenantId, attempt, maxPages: 5);
|
||||
|
||||
if (scan.Outcome == ScanOutcome.FilterRejected)
|
||||
{
|
||||
// Fallback: the endpoint rejected the filter (400) — page the unfiltered list
|
||||
// and match client-side. 60 pages x 1000 covers the portal maximum of
|
||||
// 10 CSV files x 5000 identifiers.
|
||||
_logger.LogWarning(
|
||||
"Corporate identifier validation Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}",
|
||||
tenantId, attempt, (int)response.StatusCode, responseBody);
|
||||
|
||||
// Graph errors are transient — do NOT cache
|
||||
return new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
|
||||
};
|
||||
"Corporate identifier validation: contains() filter rejected by Graph for tenant {TenantId}; falling back to unfiltered scan.",
|
||||
tenantId);
|
||||
var unfilteredUrl = $"{Constants.GraphBaseUrl}/beta/deviceManagement/importedDeviceIdentities?$top=1000";
|
||||
scan = await ScanPagesForIdentifierAsync(graphClient, unfilteredUrl, normalizedIdentifier, tenantId, attempt, maxPages: 60);
|
||||
}
|
||||
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var identities = data?["value"] as JArray;
|
||||
|
||||
if (identities == null || identities.Count == 0)
|
||||
switch (scan.Outcome)
|
||||
{
|
||||
// Definitive: device not found — cache negative result
|
||||
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier"
|
||||
}, isPositive: false);
|
||||
case ScanOutcome.Found:
|
||||
_logger.LogInformation(
|
||||
"Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier} (normalized {NormalizedIdentifier})",
|
||||
tenantId, sessionId ?? "<none>", rawIdentifier, normalizedIdentifier);
|
||||
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Identifier = rawIdentifier
|
||||
}, isPositive: true);
|
||||
|
||||
case ScanOutcome.NotFound:
|
||||
// Definitive: device not found — cache negative result
|
||||
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"Device '{rawIdentifier}' (normalized '{normalizedIdentifier}') is not registered as a Corporate Identifier (manufacturerModelSerial)"
|
||||
}, isPositive: false);
|
||||
|
||||
case ScanOutcome.PermissionDenied:
|
||||
// 401/403 = missing application permission / admin consent. Retries can
|
||||
// never heal that, so it is a DEFINITIVE failure — a transient
|
||||
// classification would trap every agent in an endless 503 Retry-After
|
||||
// loop (field case 2026-08-17). Cached like a negative lookup (5 min) so
|
||||
// a fleet of agents does not hammer Graph while consent is being fixed.
|
||||
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Corporate identifier lookup not permitted: the app registration lacks the "
|
||||
+ "DeviceManagementServiceConfig.Read.All (or ReadWrite.All) Graph permission or admin "
|
||||
+ "consent. Grant consent or disable ValidateCorporateIdentifier."
|
||||
}, isPositive: false);
|
||||
|
||||
default:
|
||||
// Transient (throttling, 5xx, page-cap exceeded) — do NOT cache
|
||||
return new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
ErrorMessage = scan.Error ?? "Graph query failed"
|
||||
};
|
||||
}
|
||||
|
||||
var result = new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Identifier = identifier
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}",
|
||||
tenantId,
|
||||
sessionId ?? "<none>",
|
||||
identifier);
|
||||
|
||||
return CacheAndReturn(cacheKey, result, isPositive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -201,6 +222,116 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScanOutcome { Found, NotFound, PermissionDenied, FilterRejected, Transient }
|
||||
|
||||
private readonly record struct ScanResult(ScanOutcome Outcome, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Pages through an importedDeviceIdentities GET url (following @odata.nextLink) and
|
||||
/// looks for a manufacturerModelSerial identity whose importedDeviceIdentifier equals
|
||||
/// <paramref name="normalizedIdentifier"/> after normalizing the stored value the same
|
||||
/// way (both sides pass through <see cref="NormalizeStoredIdentifier"/>, so raw legacy
|
||||
/// entries match too). Serial-only identities never match: they are an Intune-side
|
||||
/// misconfiguration for Windows, not authorization.
|
||||
/// </summary>
|
||||
private async Task<ScanResult> ScanPagesForIdentifierAsync(
|
||||
HttpClient graphClient, string url, string normalizedIdentifier, string tenantId, int attempt, int maxPages)
|
||||
{
|
||||
for (var page = 0; page < maxPages && !string.IsNullOrEmpty(url); page++)
|
||||
{
|
||||
var response = await graphClient.GetAsync(url);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Corporate identifier validation Graph query failed for tenant {TenantId} (attempt {Attempt}, page {Page}). Status: {StatusCode}. Body: {ResponseBody}",
|
||||
tenantId, attempt, page + 1, (int)response.StatusCode, responseBody);
|
||||
|
||||
return response.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden
|
||||
=> new ScanResult(ScanOutcome.PermissionDenied),
|
||||
// 400 on the first page = the $filter was rejected; mid-pagination a 400
|
||||
// is unexpected and treated as transient like any other Graph error.
|
||||
System.Net.HttpStatusCode.BadRequest when page == 0
|
||||
=> new ScanResult(ScanOutcome.FilterRejected),
|
||||
_ => new ScanResult(ScanOutcome.Transient, $"Graph query failed with status {(int)response.StatusCode}"),
|
||||
};
|
||||
}
|
||||
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var identities = data?["value"] as JArray;
|
||||
if (identities != null)
|
||||
{
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var type = identity?["importedDeviceIdentityType"]?.ToString();
|
||||
var value = identity?["importedDeviceIdentifier"]?.ToString();
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return new ScanResult(ScanOutcome.Found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
url = data?["@odata.nextLink"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
// Page cap exceeded with more data pending — treating this as "not found" could
|
||||
// cache a false negative, so it is transient instead.
|
||||
_logger.LogWarning(
|
||||
"Corporate identifier validation exceeded {MaxPages} pages for tenant {TenantId} without exhausting the list.",
|
||||
maxPages, tenantId);
|
||||
return new ScanResult(ScanOutcome.Transient, "Corporate identifier list scan exceeded the page budget");
|
||||
}
|
||||
|
||||
return new ScanResult(ScanOutcome.NotFound);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes one identifier component the way the Intune portal does on upload:
|
||||
/// uppercase, every non-alphanumeric character removed ("Microsoft Corporation" →
|
||||
/// "MICROSOFTCORPORATION", "7801-5131-…-18" → "78015131…18"). Field-verified 2026-08-17
|
||||
/// against the portal's stored corporate identifier list.
|
||||
/// </summary>
|
||||
internal static string NormalizeComponent(string value)
|
||||
{
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c))
|
||||
sb.Append(char.ToUpperInvariant(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
internal static string BuildNormalizedIdentifier(string manufacturer, string model, string serial) =>
|
||||
$"{NormalizeComponent(manufacturer)},{NormalizeComponent(model)},{NormalizeComponent(serial)}";
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a stored importedDeviceIdentifier for comparison: same per-character rule
|
||||
/// as <see cref="NormalizeComponent"/> but keeps the comma separators. Stored values are
|
||||
/// already normalized by the portal; running them through again is a no-op there and
|
||||
/// makes raw-form entries (e.g. created via Graph directly) match as well.
|
||||
/// </summary>
|
||||
internal static string NormalizeStoredIdentifier(string value)
|
||||
{
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == ',')
|
||||
sb.Append(c);
|
||||
else if (char.IsLetterOrDigit(c))
|
||||
sb.Append(char.ToUpperInvariant(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string tenantId, string manufacturer, string model, string serialNumber)
|
||||
{
|
||||
return $"corporate-id-validation:{tenantId}:{manufacturer}:{model}:{serialNumber}";
|
||||
|
||||
@@ -362,8 +362,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = corpResult.ErrorMessage;
|
||||
deviceValidationTransient = corpResult.IsTransient;
|
||||
// Accumulate instead of overwrite: with several validators enabled, only the
|
||||
// LAST failure used to surface in the 403 Details / rejection log, hiding why
|
||||
// the earlier (usually more relevant) lookups missed. Transient is sticky-OR:
|
||||
// if ANY validator failed transiently the device might actually be authorized,
|
||||
// so the caller must return 503 Retry-After rather than a definitive 403.
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, corpResult.ErrorMessage);
|
||||
deviceValidationTransient |= corpResult.IsTransient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +389,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = cloudPcResult.ErrorMessage;
|
||||
deviceValidationTransient = cloudPcResult.IsTransient;
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
|
||||
deviceValidationTransient |= cloudPcResult.IsTransient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +471,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
|
||||
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
|
||||
/// last validator's.
|
||||
/// </summary>
|
||||
internal static string? CombineValidationErrors(string? accumulated, string? next)
|
||||
{
|
||||
if (string.IsNullOrEmpty(next)) return accumulated;
|
||||
if (string.IsNullOrEmpty(accumulated)) return next;
|
||||
return accumulated + " | " + next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
|
||||
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
|
||||
|
||||
@@ -199,7 +199,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (existingLookup.TryGetValue(pattern.PatternId, out var existing))
|
||||
{
|
||||
if (existing.Pattern != pattern.Pattern || existing.Action != pattern.Action
|
||||
|| existing.Category != pattern.Category || existing.Description != pattern.Description)
|
||||
|| existing.Category != pattern.Category || existing.Description != pattern.Description
|
||||
|| existing.Enabled != pattern.Enabled
|
||||
|| !ParametersEqual(existing.Parameters, pattern.Parameters))
|
||||
{
|
||||
await _ruleRepo.StoreImeLogPatternAsync(pattern, "global");
|
||||
updated++;
|
||||
@@ -220,5 +222,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
_seeded = true;
|
||||
}
|
||||
|
||||
private static bool ParametersEqual(Dictionary<string, string>? a, Dictionary<string, string>? b)
|
||||
{
|
||||
if ((a?.Count ?? 0) != (b?.Count ?? 0)) return false;
|
||||
if (a == null || b == null) return true;
|
||||
|
||||
foreach (var kv in a)
|
||||
{
|
||||
if (!b.TryGetValue(kv.Key, out var value) || value != kv.Value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Both stages run in the same 2h maintenance pass so no new timers are introduced
|
||||
/// (preserving Container App scale-to-zero).
|
||||
/// </summary>
|
||||
private async Task MarkStalledSessionsAsTimedOutAsync()
|
||||
/// <summary>
|
||||
/// Outcome of one stalled-session sweep pass. <see cref="Error"/> is null on success;
|
||||
/// on a catastrophic failure (e.g. the tenant enumeration itself failed) it carries the
|
||||
/// exception message and the counters are 0 — per-tenant failures are caught inside the
|
||||
/// loop and never surface here. Consumed by the hourly <c>SessionSweep</c> timer to emit
|
||||
/// its Completed/Failed OpsEvent; the 2h maintenance chain ignores the result (fail-soft).
|
||||
/// </summary>
|
||||
internal sealed record SessionSweepResult(int StalledMarked, int TimedOut, string? Error = null);
|
||||
|
||||
internal async Task<SessionSweepResult> MarkStalledSessionsAsTimedOutAsync()
|
||||
{
|
||||
_logger.LogInformation("Checking for stalled sessions...");
|
||||
var stalledStart = Stopwatch.StartNew();
|
||||
@@ -543,10 +552,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
stalledStart.Stop();
|
||||
_logger.LogInformation($"Stalled session check completed: {totalSessionsMarkedStalled} marked Stalled, {totalSessionsTimedOut} timed out in {stalledStart.ElapsedMilliseconds}ms");
|
||||
return new SessionSweepResult(totalSessionsMarkedStalled, totalSessionsTimedOut);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check for stalled sessions");
|
||||
return new SessionSweepResult(0, 0, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
$"Maintenance took {durationMs}ms (> {thresholdMinutes}min soft threshold; host aborts at 60min) — triggered by {triggeredBy}",
|
||||
null, triggeredBy, new { durationMs, thresholdMinutes });
|
||||
|
||||
/// <summary>
|
||||
/// Heartbeat of the hourly <c>SessionSweep</c> timer (the :30 interleave of the stalled-session
|
||||
/// sweep — the 2h Maintenance chain still runs the same sweep at minute 0). Emitted every tick,
|
||||
/// counts included, so a dead timer is detectable and dashboards can separate the interleave
|
||||
/// from full maintenance runs.
|
||||
/// </summary>
|
||||
public Task RecordSessionSweepCompletedAsync(int stalledMarked, int timedOut, int durationMs)
|
||||
=> WriteAsync(OpsEventCategory.Maintenance, "SessionSweepCompleted", OpsEventSeverity.Info,
|
||||
$"Hourly session sweep completed in {durationMs}ms — {stalledMarked} marked Stalled, {timedOut} terminalized",
|
||||
null, "System.SessionSweep", new { stalledMarked, timedOut, durationMs });
|
||||
|
||||
public Task RecordSessionSweepFailedAsync(string error)
|
||||
=> WriteAsync(OpsEventCategory.Maintenance, "SessionSweepFailed", OpsEventSeverity.Error,
|
||||
$"Hourly session sweep failed: {error}",
|
||||
null, "System.SessionSweep", new { error });
|
||||
|
||||
public Task RecordOpsEventCleanupAsync(int deletedCount, int retentionDays)
|
||||
=> WriteAsync(OpsEventCategory.Maintenance, "OpsEventCleanup", OpsEventSeverity.Info,
|
||||
$"Cleaned up {deletedCount} ops events older than {retentionDays} days",
|
||||
|
||||
@@ -25,6 +25,12 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
"MaintenanceLongRunning",
|
||||
"OpsEventCleanup",
|
||||
"SessionTimeouts",
|
||||
// Hourly stalled-session sweep interleave (SessionSweepFunction, minute 30) — dual-register
|
||||
// per memory feedback_ops_event_types_dual_register. Backend helpers
|
||||
// RecordSessionSweep{Completed,Failed}Async. Completed is an hourly heartbeat (Info);
|
||||
// Failed fires only when the sweep's tenant enumeration itself blows up (Error).
|
||||
"SessionSweepCompleted",
|
||||
"SessionSweepFailed",
|
||||
// Cascade-delete maintenance (PR6) — dual-register per memory feedback_ops_event_types_dual_register.
|
||||
// Dispatched by SessionDeletionMaintenanceFunction; backend helpers in OpsEventService.cs are
|
||||
// RecordSessionDeletionMaintenance{LongRunning,LongRunningSevere,Failed,Completed,FanoutSkipped}Async +
|
||||
|
||||
Reference in New Issue
Block a user