mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c770bbffcf | |||
| 3fbdc8ef62 | |||
| 35cea4fe64 | |||
| ade6c4948f | |||
| 83da522dd7 | |||
| c15394df87 | |||
| 810a6f1f2f | |||
| 019ac3959e | |||
| 9236aba46e | |||
| fda52666d6 | |||
| dcec6a1863 | |||
| 041626494e | |||
| fd32a90d57 | |||
| 90b8243894 | |||
| 66d25f57db | |||
| 84a0424789 | |||
| 596788cc26 | |||
| aec2d249e1 | |||
| a63f126b88 | |||
| d94a91780c | |||
| 0b83fe2390 | |||
| 75c0c60717 | |||
| 29e7aa675a | |||
| c28d64fe27 | |||
| b2723168fd | |||
| d05d8ef302 | |||
| 2c48968ec6 | |||
| 2f8ad82b0a | |||
| 4b74995e79 | |||
| 5d8d9e8d40 | |||
| 25bd2c175e | |||
| 830edb98e7 | |||
| 1ebc2675b1 | |||
| 283c2ed214 | |||
| 13dd6723da | |||
| cb6d80a76e | |||
| 024c90d68f | |||
| 54044c5d9c | |||
| 3b3e8a7614 | |||
| 115b4ec8c2 | |||
| 8ec5a5debd | |||
| ff35270d5d | |||
| 312e849573 | |||
| 7433df528d | |||
| d86ae18a5e | |||
| 1c5f69a9fe | |||
| 17affb6dfd | |||
| acf4d0eeaa | |||
| 4c1889d011 | |||
| da4de79a9d | |||
| 6f3f35e1cd | |||
| ca22be5cd0 | |||
| c421b81751 | |||
| 7636a00e24 | |||
| 90294455e0 | |||
| 4def73f193 | |||
| 6bab2d3db9 | |||
| 78b8dd0f1b | |||
| 14acfd5c59 | |||
| 22d4c6c677 | |||
| 9fce499f4c | |||
| 8c7a6410d2 | |||
| c158768343 | |||
| 75197a4184 | |||
| 179a441e64 | |||
| acc95471a6 | |||
| 29ced81d6e | |||
| 4966d23a46 | |||
| 59ba8301a8 | |||
| b142fa0994 | |||
| afa8750cbb | |||
| 3c1229a301 | |||
| b4e409783e | |||
| eb94c612e7 | |||
| ed5fcf3dc3 | |||
| 9ba67162aa | |||
| efc96258d6 | |||
| 9d8b0fc254 | |||
| f3028041af | |||
| 3628c7bc79 | |||
| 395fdd49df | |||
| 5edb64f3c9 | |||
| 46965af290 | |||
| 1bfdb68ff6 | |||
| 5ee5114d5c | |||
| a7bb7ebf15 | |||
| a7a1cc43c1 | |||
| 2a5ad65dd7 | |||
| 960b3d8ed9 | |||
| 86bb255b11 | |||
| d3a10dfb96 | |||
| 80d0b30cba | |||
| aee723e497 | |||
| 427c923530 | |||
| b46a8f1571 | |||
| 6e294c010e | |||
| dc54c9555c | |||
| 784fd99240 | |||
| 607d6615b0 | |||
| 8d4f7ea0f9 | |||
| 0e2d620ad4 | |||
| 69d61c6c26 | |||
| cfb28d83b6 | |||
| f795fbcb4e | |||
| d9d22bc443 | |||
| 6bd5728652 | |||
| d6affae8fc | |||
| a70c5de667 | |||
| e62bac9fea | |||
| 1e94a7aa91 | |||
| c330e5c91a | |||
| 6015548172 | |||
| 3f661d097d | |||
| 3392a23da6 | |||
| 5312216f6b | |||
| 72392f6f4c | |||
| d65157a092 | |||
| cf45911b69 | |||
| 1593232898 | |||
| e4c06a9564 | |||
| 9c50d98792 | |||
| 20b43e5349 | |||
| d0d1d28371 | |||
| 5191751ed2 | |||
| 907118649b | |||
| 26ecbf33d6 | |||
| adb103fc9e | |||
| 26c8b80efa | |||
| 89c5c7da03 | |||
| 48308df4ff | |||
| cefa57fde5 | |||
| 946d503be0 | |||
| f744ad8406 | |||
| 5e9876bc5f | |||
| ffe88a47c6 | |||
| 1f22b65d57 | |||
| 03afbf6ac6 | |||
| 36172b8cc5 | |||
| 8849a68b1c | |||
| cc195990ff | |||
| 18b28260a7 | |||
| 6cbd507b4f | |||
| 26bcab9054 | |||
| 10308664dd |
+1
-1
Submodule .claude/commands updated: 997df4b5ed...0766ebfef6
@@ -194,8 +194,8 @@ jobs:
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
@@ -270,6 +270,12 @@ jobs:
|
||||
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
|
||||
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
|
||||
# Customer-side Graph add-on grant script -- docs and admin UI link
|
||||
# https://download.autopilotmonitor.com/agent/Grant-AutopilotMonitorAddOn.ps1
|
||||
if (Test-Path "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1") {
|
||||
Send-Blob -Local "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1" -BlobName "Grant-AutopilotMonitorAddOn.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
|
||||
|
||||
@@ -69,9 +69,20 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -95,8 +106,22 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
|
||||
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
|
||||
# node_modules by default, which npm ci recreates — so every CI run
|
||||
# re-downloaded from huggingface.co and rate-limits (429) failed the job
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
env:
|
||||
HF_CACHE_DIR: ${{ github.workspace }}/.hf-cache
|
||||
|
||||
guardrails-in-sync:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -39,6 +39,10 @@ jobs:
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
# and the default app for browsers without a stored selection (legacy|primary).
|
||||
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
|
||||
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
@@ -52,7 +56,12 @@ jobs:
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
output_location: "" # Built app content directory - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
close_pull_request_job:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
|
||||
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
|
||||
# verified without resetting a device. Like ci.yml it reports rather than blocks.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
@@ -53,6 +53,9 @@ __azurite_db*__.json
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
# Managed by Next.js since v16 (regenerated by next dev/build/typegen; path
|
||||
# inside flips between .next/types and .next/dev/types) — must not be tracked.
|
||||
next-env.d.ts
|
||||
build/
|
||||
dist/
|
||||
!rules/dist/
|
||||
@@ -148,3 +151,6 @@ plans/REFACTOR_AGENT_V2.md
|
||||
scripts/Backup/setup-backup-container.ps1
|
||||
docs/Introduction-AutopilotMonitor.pptx
|
||||
docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"autopilot-monitor": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.autopilotmonitor.com/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "cmd",
|
||||
"args": ["/c", "npx", "@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
|
||||
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
|
||||
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
|
||||
against an already-referenced package must not block an unrelated hotfix deploy;
|
||||
advisories are handled by bumping the package, not by failing the build. -->
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,14 +1,14 @@
|
||||
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
|
||||
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](LICENSE)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
|
||||
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
|
||||
|
||||
## Private Preview
|
||||
## Availability
|
||||
|
||||
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
|
||||
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/SessionList.png" width="45%" />
|
||||
|
||||
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
|
||||
evidence-backed verdict, and the backend rule engine uses the error-code map for
|
||||
known-issue enrichment.
|
||||
|
||||
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
|
||||
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
|
||||
service, not by Autopilot. The event is still emitted with the full evidence payload plus
|
||||
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
|
||||
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
|
||||
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
|
||||
(`session.isCloudPc` or the event marker), the amber warning otherwise.
|
||||
|
||||
Consumers in this repo:
|
||||
|
||||
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Continue-Anyway Observation Mode — Device-Phase ESP Failure Defang
|
||||
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
|
||||
resource: /src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
|
||||
tags:
|
||||
- agent
|
||||
- decision-engine
|
||||
- esp
|
||||
- continue-anyway
|
||||
- soft-failure
|
||||
- tenant-config
|
||||
timestamp: 2026-08-08T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Continue-Anyway Observation Mode
|
||||
|
||||
## Problem
|
||||
|
||||
A Classic user-driven enrollment whose Device ESP hits the configured terminal timeout
|
||||
(`syncFailureTimeoutMinutes`, typically 30) fails **before AccountSetup is ever entered**.
|
||||
The classic advisory defang (`HandleEspTerminalFailureV1`) requires
|
||||
`AccountSetupEnteredUtc != null`, so these sessions hard-fail immediately — even when the
|
||||
ESP profile allows "Continue anyway" and users demonstrably dismiss the failure screen and
|
||||
reach a working desktop. A fleet held at the timeout wall by one slow blocking app then
|
||||
shows ~100% Failed (tenant c9787ba2: 948/1000 `esp_terminal_failure`, culprit "Encompass
|
||||
Hybrid Installer"), which tells the tenant nothing.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Opt-in flow (default off — absence keeps the immediate hard fail):
|
||||
|
||||
1. **Tenant setting** `EnableEspContinueAnywayObservation` (`TenantConfiguration`,
|
||||
operator-set only, NOT exposed in the tenant admin settings UI; the toggle lives in
|
||||
the Global-Admin tenant management modal next to Bootstrap Token / Unrestricted Mode,
|
||||
and the field is GA-gated on both write paths — the generic PUT silently reverts it
|
||||
for tenant admins, the field patch rejects it with 400) → served via `agent/config`
|
||||
(`AgentConfigResponse`, ConfigVersion 37).
|
||||
2. **Agent** stamps `espContinueAnywayObservationEnabled=true` into the
|
||||
`EspConfigDetected` signal payload (both emitters: the orchestrator FirstSync
|
||||
bootstrap and `DeviceInfoCollector.PostEspConfigDetectedSignal`) — only when enabled,
|
||||
so the kernel stays config-free and replay/recovery reconstruct the fact from the
|
||||
signal log.
|
||||
3. **Engine** (`HandleEspTerminalFailureV1`): a terminal ESP failure is defanged into an
|
||||
`esp_failure_advisory` (`advisoryReason=esp_failure_defanged_continueanyway_observation`)
|
||||
when ALL hold: profile allows Continue-Anyway, observation opt-in observed,
|
||||
`AccountSetupEnteredUtc == null`, and the scenario is Classic/Unknown with
|
||||
`PreProvisioningSide=None` and no registry self-deploying marker (WhiteGlove /
|
||||
SelfDeploying / DevicePreparation have no interactive user to press the button).
|
||||
4. **Observation window**: the shared `AdvisoryCompletion` deadline is armed with a
|
||||
**60-minute** window (classic advisory: 30) — long enough for failure screen →
|
||||
Continue anyway → sign-in → desktop, or a full device-phase re-run via "Try again".
|
||||
|
||||
## Detecting the real end
|
||||
|
||||
The active-advisory-without-AccountSetup shape is unique to this arming site, so
|
||||
`IsContinueAnywayObservationActive(state)` (advisory recorded ∧ not resolved ∧
|
||||
AccountSetup never entered) needs no separate opt-in re-check. Three outcomes:
|
||||
|
||||
* **Continue anyway** — the DAD-validated real-user desktop arrives (defaultuser0/SYSTEM
|
||||
excluded at the detector) and the Hello gate is satisfied (resolved, or policy
|
||||
explicitly disabled without an observed wizard): `HandleDesktopArrivedV1` completes
|
||||
eagerly through Finalizing (trigger `DesktopArrived:ContinueAnywayObservation`) — the
|
||||
session end reflects the real end, not the window edge. The IME user-session gate is
|
||||
deliberately waived: it anchors on `AccountSetupEnteredUtc`, which is structurally
|
||||
absent here. The terminal `enrollment_complete` carries `espSoftFailure=true`,
|
||||
`completionSource=continue_anyway_observation` and the failed category; the backend
|
||||
stamps `EspSoftFailure`/`CompletionSource` on the Sessions row (+ SessionsIndex
|
||||
mirror) and the web renders the amber "with issues" badge plus a detail banner.
|
||||
Sessions completing under the CLASSIC advisory (AccountSetup entered) carry the same
|
||||
marker with `completionSource=continue_anyway_post_accountsetup`.
|
||||
* **Try again** — the ESP re-runs the failed category; `DeviceSetupProvisioningComplete`
|
||||
resolves the advisory via the 4910a5a5 recovery hook (`esp_failure_advisory_resolved`)
|
||||
and the session continues on the normal rails, completing clean (no marker).
|
||||
* **Nothing** — the window expires: un-defang to `enrollment_failed` with the original
|
||||
`esp_terminal_failure` reason and
|
||||
`advisoryReason=continue_anyway_observation_window_expired_without_completion_evidence`;
|
||||
`LastFailureTrigger=EspTerminalFailure` keeps the likely-stuck app promotion on. Every
|
||||
observed session therefore still gets a terminal verdict ≤ ~60 min after the failure.
|
||||
|
||||
Deadline-fire edge cases mirror the classic paths: desktop + unknown Hello promotes to
|
||||
`AwaitingHello` with the HelloSafety window (never-observed promote), and enforcement
|
||||
progress since arming re-arms the window (30-min re-arm, convergent) instead of failing a
|
||||
device mid-"Try again".
|
||||
|
||||
## Status semantics
|
||||
|
||||
No new terminal status: the session is `Succeeded` + `EspSoftFailure=true` (user
|
||||
decision 2026-08-08). Success-rate conventions stay untouched; honesty lives in the
|
||||
badge, the detail banner, and ANALYZE-ESP-004 (which continues to fire on the un-defang
|
||||
expiry path via `mayHaveContinuedAnyway`).
|
||||
|
||||
# Examples
|
||||
|
||||
Session `53d1e9f6-4eef-4f3b-a999-c63e96c5148d` (tenant c9787ba2, 2026-08-07) is the
|
||||
motivating shape: DeviceSetup/Certificates failed at the 30-min wall while "Encompass
|
||||
Hybrid Installer" was still installing, AccountSetup 0/5, Continue-Anyway allowed —
|
||||
hard-failed under the default semantics.
|
||||
|
||||
Tests: `ContinueAnywayObservationTests` (DecisionCore.Tests) — defang, 60-min arming,
|
||||
opt-out/scenario regressions, eager desktop completion with marker, clean-completion
|
||||
marker absence, expiry un-defang, hello promotes, category recovery.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — arming site,
|
||||
observation branches in `HandleAdvisoryCompletionDeadlineFired`,
|
||||
`IsContinueAnywayObservationActive`.
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` —
|
||||
`HandleDesktopArrivedV1` observation completion, `BuildEnrollmentCompleteEffect`
|
||||
soft-failure marker.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs`
|
||||
/ `TableStorageService.Sessions.cs` — session stamping + index mirror.
|
||||
* `src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx` — amber badge.
|
||||
@@ -58,14 +58,18 @@ when disabled — zero cost on the normal path). Line format:
|
||||
2026-07-28T09:15:02.123Z | RULE-ID | stage | message
|
||||
```
|
||||
|
||||
Stages: `config` (registration summary per rule, header, zero-rules note), `trigger`
|
||||
(timer scheduled, phase/event fired, dedup), `scope` (skips with reason incl. current
|
||||
phase), `exec` (execution start), `collector` (null/empty results with likely cause),
|
||||
`guard` (allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
|
||||
Stages: `config` (registration summary per rule, header, zero-rules note), `phase`
|
||||
(enrollment-phase transition marker `phase change: Old -> New`, ruleId `-` — every
|
||||
rule-level line below it reads against the new phase), `trigger` (timer scheduled,
|
||||
phase/event fired, dedup), `scope` (skips with reason incl. current phase), `exec`
|
||||
(execution start), `collector` (null/empty results with likely cause), `guard`
|
||||
(allowlist block), `emit` (event emitted, hash change), `suppress` (on_change
|
||||
suppression with hash prefix + streak), `logparser` (per-file outcome: lines read,
|
||||
position range, match count, parse failures, regex timeouts, mode; plus the
|
||||
`format=text` hint when every line fails CMTrace parsing), `error` (full exception
|
||||
**with stack trace** — the agent log keeps only the message).
|
||||
`format=text` hint when every line fails CMTrace parsing; plus one line **per regex
|
||||
match** — line number, matched text, capture groups — capped at 10 per file per run),
|
||||
`error` (full exception **with stack trace** — the agent log keeps only the message;
|
||||
an invalid logparser regex is reported here including the offending pattern string).
|
||||
|
||||
Volume control: 10 MB cap with a single `.old` rotation generation (~20 MB worst case).
|
||||
Every write failure is swallowed — tracing must never break gathering. The trace never
|
||||
@@ -92,6 +96,7 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
```
|
||||
... | LP-01 | config | registered: trigger=interval, collector=logparser, target=C:\...\app.log, unscoped, emitMode=on_change, interval=300s
|
||||
... | LP-01 | trigger | interval timer scheduled every 300s (first run after one full interval — no immediate execution)
|
||||
... | - | phase | phase change: DeviceSetup -> AccountSetup
|
||||
... | LP-01 | exec | executing: collector=logparser, target=C:\...\app.log
|
||||
... | LP-01 | logparser | app.log: read 1000 lines from position 0->81234, matched 0, parseFailures=1000, regexTimeouts=0, mode=cmtrace
|
||||
... | LP-01 | logparser | every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text
|
||||
@@ -99,6 +104,19 @@ A logparser rule with `emit_mode=on_change` that "delivers nothing" becomes:
|
||||
|
||||
— the answer ("plain-text log parsed in CMTrace mode") is in the file.
|
||||
|
||||
A matching rule shows *how* it matched (capped at 10 per-match lines per file per run):
|
||||
|
||||
```
|
||||
... | LP-02 | logparser | setupact.log: line 42 matched "Error 0x80070005" — groups: code="0x80070005"
|
||||
... | LP-02 | logparser | setupact.log: read 500 lines from position 0->40123, matched 1, parseFailures=0, regexTimeouts=0, mode=text
|
||||
```
|
||||
|
||||
and a broken regex names itself:
|
||||
|
||||
```
|
||||
... | LP-03 | error | invalid regex pattern '([unclosed' — rule can never match: Invalid pattern '([unclosed' at offset 10. Not enough )'s.
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
* Executor + trace points: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs`
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: Concept
|
||||
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
|
||||
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- device-validation
|
||||
- windows365
|
||||
- cloudpc
|
||||
- graph
|
||||
timestamp: 2026-08-06T00:00:00+02:00
|
||||
---
|
||||
|
||||
# W365 Cloud PC Device Validation
|
||||
|
||||
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
|
||||
**structurally never Autopilot-registered** — the serial lookup against
|
||||
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
|
||||
serials nobody can pre-import for VMs minted at provisioning time) always misses.
|
||||
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
|
||||
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
|
||||
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
|
||||
|
||||
# Schema — the validator chain
|
||||
|
||||
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
|
||||
device; there is **no device-type routing** and the local W365 markers
|
||||
(Windows365 registry key, CloudManagedDesktopExtension service) are never
|
||||
consulted for auth — they gain an attacker nothing:
|
||||
|
||||
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
|
||||
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
|
||||
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
|
||||
missed. Extracts the Intune device id from the **chain-validated** client
|
||||
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
|
||||
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
|
||||
with `managedDeviceId eq <CN>` in the claimed tenant
|
||||
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
|
||||
`ValidatedBy = ValidatorType.CloudPc (5)`.
|
||||
|
||||
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
|
||||
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
|
||||
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
|
||||
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
|
||||
|
||||
## Why the cert CN, not headers or local markers
|
||||
|
||||
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
|
||||
|
||||
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
|
||||
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
|
||||
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
|
||||
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
|
||||
paging fallback needed.
|
||||
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
|
||||
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
|
||||
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
|
||||
Model='Virtual Machine').
|
||||
|
||||
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
|
||||
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
|
||||
binding* the serial-based validators do not have); spoofed serial headers and
|
||||
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
|
||||
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
|
||||
device-reported inventory — the reason the model-string gate was rejected).
|
||||
Retired/deprovisioned Cloud PCs disappear from the inventory and their
|
||||
validation dies with them (compensates the absent cert revocation check).
|
||||
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
|
||||
as any tenant device today, rate-limited by cert thumbprint.
|
||||
|
||||
## Graph permission plumbing
|
||||
|
||||
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
|
||||
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
|
||||
surfaces automatically in the "Optional Graph capabilities" UI (the feature
|
||||
table and grant command are backend-driven) and in
|
||||
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
|
||||
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
|
||||
comment-only lock-step).
|
||||
- A Graph **403** (permission not granted) is a definitive negative (cached
|
||||
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
|
||||
The error message names the add-on feature.
|
||||
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
|
||||
positive/negative cache, 2 attempts, transient → agent-facing 503.
|
||||
|
||||
## Bootstrap script interplay (v2.3-dev.3)
|
||||
|
||||
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
|
||||
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
|
||||
user's first connect, and the monitorable phase (Account Setup) only starts at
|
||||
that first connect — the single-fresh-profile window (< 15 min) is the time
|
||||
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
|
||||
|
||||
## Agent-side IsCloudPc flag + decision-engine expectation
|
||||
|
||||
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
|
||||
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
|
||||
installed `CloudManagedDesktopExtension` service (probed via its
|
||||
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
|
||||
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
|
||||
|
||||
Three independent transport rails, never an auth input:
|
||||
|
||||
- **Session metadata** — `SessionRegistration.IsCloudPc` →
|
||||
Sessions table (sticky-true OR across re-registrations, like
|
||||
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
|
||||
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
|
||||
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
|
||||
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
|
||||
(e.g. Cloud PC admitted via a stage that ran earlier).
|
||||
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
|
||||
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
|
||||
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
|
||||
Registered" report aggregates it **sticky-true** per serial
|
||||
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
|
||||
badge plus a hint pointing at the Cloud PC validation toggle + the
|
||||
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
|
||||
fields; rows from older agents read false. Fits the existing 1536-byte
|
||||
payload cap without a bump.
|
||||
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
|
||||
(`isCloudPc`), is recorded set-once for BOTH values in
|
||||
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
|
||||
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
|
||||
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
|
||||
headless at provisioning, the session starts at Account Setup. Deliberately
|
||||
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
|
||||
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
|
||||
keeps the *configured* FirstSync semantics. The positive marker surfaces as
|
||||
`cloud_pc_marker` in the terminal audit-trail signal census.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
|
||||
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
|
||||
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
|
||||
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
type: Concept
|
||||
title: OOBE Bootstrap go-Code Flow — Always-200 Script Endpoint
|
||||
description: The full lifecycle of the bootstrap short-code — created in the portal, typed as `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10, served by GetBootstrapScriptFunction as an always-HTTP-200 PowerShell script — including the two-line injection defense and the rate-limit story behind Front Door.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- bootstrap
|
||||
- security
|
||||
- oobe
|
||||
timestamp: 2026-07-28T00:00:00+02:00
|
||||
---
|
||||
|
||||
# OOBE Bootstrap go-Code Flow
|
||||
|
||||
Bootstrap Sessions let a technician install the agent **before** Intune
|
||||
enrollment: an admin creates a short-lived session in the portal (Settings →
|
||||
Bootstrap Sessions), hands the technician a short URL, and the technician types
|
||||
`irm https://go.autopilotmonitor.com/CODE | iex` into a Shift+F10 console
|
||||
during OOBE. The URL is deliberately short because it is typed by hand on a
|
||||
device that has no clipboard.
|
||||
|
||||
# Lifecycle
|
||||
|
||||
1. **Create** — `POST /api/bootstrap/sessions` (`BootstrapManagerOrGA`).
|
||||
`BootstrapSessionService.CreateAsync` mints an unambiguous-charset short
|
||||
code + a GUID token, validity clamped 1–168 h. The response's `bootstrapUrl`
|
||||
is built from `Constants.BootstrapGoBaseUrl` — the single producer of the
|
||||
absolute URL (pinned by `CreateBootstrapSessionUrlShapeTests`).
|
||||
2. **Serve** — `GET /api/bootstrap/go/{code}` (`GetBootstrapScriptFunction`,
|
||||
`PublicAnonymous`). Customers reach it through the Front Door custom domain
|
||||
`go.autopilotmonitor.com`, whose `/*` route rewrites onto the API path.
|
||||
Validation runs in-process (`ValidateCodeAsync` + the tenant's
|
||||
`BootstrapTokenEnabled` gate) — no HTTP hop to the validate endpoint.
|
||||
3. **Install** — the generated script downloads the agent ZIP from
|
||||
`Constants.AgentDownloadBaseUrl`, extracts, and runs
|
||||
`--install --no-auth --bootstrap-token … --tenant-id …`. The agent monitors
|
||||
from OOBE start, switches to certificate auth after Intune enrollment, and
|
||||
self-destructs on completion.
|
||||
|
||||
The legacy Next.js `/go/[code]` route (SSR on the SWA) stays alive only for
|
||||
URLs issued before the migration and is removed with the static-export PR —
|
||||
see the transition rule in [URL Registry](../url-registry.md).
|
||||
|
||||
# Always-200 contract
|
||||
|
||||
Every response — success, invalid format, unknown/expired code, disabled
|
||||
feature, rate limit, internal error — is **HTTP 200 `text/plain`**. The
|
||||
consumer is `irm | iex`: a non-2xx status would make `irm` throw an opaque
|
||||
exception on the OOBE console, while a 200 error script surfaces the actual
|
||||
message via `Write-Host 'ERROR: …'`. Consequences:
|
||||
|
||||
- Failures are invisible in status-code metrics; the compensating signal is the
|
||||
`LogWarning` per rejection (code + client IP).
|
||||
- Error messages are capped at 200 chars **before** quote-escaping and
|
||||
single-quote-doubled — a hostile upstream message cannot break out of the
|
||||
`Write-Host` literal.
|
||||
|
||||
A disabled tenant returns the **same generic message** as an unknown code — no
|
||||
enumeration oracle for whether a code exists.
|
||||
|
||||
# Injection defense (two lines)
|
||||
|
||||
The script runs as SYSTEM during OOBE, so interpolation is treated as a
|
||||
security boundary:
|
||||
|
||||
1. **`BootstrapScriptValueValidator`** — tenantId/token must be exact canonical
|
||||
GUIDs, the download URL must be https on an allow-listed host
|
||||
(`AgentDownloadBaseUrl`/`AgentBlobBaseUrl` hosts) with the strict
|
||||
`/agent/….zip` path shape, expiry must be future and ≤ 14 days. No accepted
|
||||
value can contain `$`, quotes, backticks, spaces, or newlines. Kept even
|
||||
though values are produced in-process — defense-in-depth against tampered
|
||||
table data.
|
||||
2. **Single-quoted PS literals** — every interpolated value sits inside
|
||||
single-quoted PowerShell strings, which do not expand `$()`, `$var`, or
|
||||
backticks.
|
||||
|
||||
The template is a C# raw string with `__TOKEN__` placeholders substituted via
|
||||
`Replace` (never `$`-interpolation — the script is full of PS `$vars`).
|
||||
`OobeBootstrapScriptGeneratorTests` pins byte parity with the original
|
||||
TypeScript template through a golden file, so the legacy route and the backend
|
||||
serve identical scripts during the transition.
|
||||
|
||||
# Rate limiting behind Front Door
|
||||
|
||||
The endpoint self-limits per client IP (`bootstrap-script:{ip}`, 20/min via
|
||||
`ClientIpExtractor.GetRateLimitClientIp`) — anonymous routes are skipped by
|
||||
`UserRateLimitMiddleware` and must bring their own limiter. Because traffic
|
||||
arrives through Front Door, the rightmost X-Forwarded-For hop resolves to AFD
|
||||
egress IPs and buckets would collapse (the shared-bucket 429 incident class);
|
||||
the AFD-aware extraction therefore honors `X-Azure-ClientIP` only when
|
||||
`X-Azure-FDID` matches the `FrontDoorProfileId` app setting (fail-closed to
|
||||
the trusted-hop path otherwise). Verified post-deploy 2026-07-29: real client
|
||||
IPs are keyed, not AFD egress.
|
||||
|
||||
Front Door route caching is DISABLED for this route: the body inlines the
|
||||
bearer token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` covers the `/api/bootstrap/go/` prefix.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/GetBootstrapScriptFunction.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/OobeBootstrapScriptGenerator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/BootstrapScriptValueValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OobeBootstrapScriptGeneratorTests.cs` — golden parity
|
||||
- `src/Web/autopilot-monitor-web/app/go/[code]/route.ts` — legacy route (transition only)
|
||||
- [URL Registry](../url-registry.md) — go-URL registry entries + transition rule
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Annotations — Role-Separated Human Verdicts
|
||||
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- web
|
||||
- mcp
|
||||
- annotations
|
||||
- authorization
|
||||
- rule-quality
|
||||
timestamp: 2026-08-11T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
|
||||
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
|
||||
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
|
||||
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
|
||||
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
|
||||
|
||||
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
|
||||
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
|
||||
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
|
||||
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
|
||||
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
|
||||
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
|
||||
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
|
||||
|
||||
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
|
||||
(there is no DELETE route). Author identity is always stamped server-side from the JWT
|
||||
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
|
||||
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
|
||||
|
||||
# Authorization
|
||||
|
||||
Routes (all in `EndpointAccessPolicyCatalog`):
|
||||
|
||||
| Route | Policy | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
|
||||
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
|
||||
|
||||
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
|
||||
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
|
||||
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
|
||||
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
|
||||
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
|
||||
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
|
||||
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
|
||||
GA writes exactly the `globaladmin` lane (the platform labeling flow).
|
||||
|
||||
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
|
||||
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
|
||||
admins). Both list endpoints enforce the same rule server-side via the repository's
|
||||
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
|
||||
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
|
||||
members of that tenant.
|
||||
|
||||
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
|
||||
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
|
||||
in the tenant-visible audit log — same convention as GA session-report submissions).
|
||||
|
||||
# Lifecycle
|
||||
|
||||
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
|
||||
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
|
||||
steps so the `sessionAnnotations` count covers all lanes.
|
||||
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
|
||||
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
|
||||
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
|
||||
expensive data on the platform to lose.
|
||||
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
|
||||
raw `list_tables`/`query_table` tools.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
|
||||
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
|
||||
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
|
||||
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
|
||||
count + verdict-pill summary header.
|
||||
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
|
||||
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
|
||||
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
|
||||
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
|
||||
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
|
||||
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
|
||||
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
|
||||
middleware-validated `TargetTenantId`, and for callers without global scope the repository
|
||||
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
|
||||
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
|
||||
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
|
||||
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
|
||||
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
|
||||
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
|
||||
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
|
||||
`tenantId`.
|
||||
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
|
||||
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
|
||||
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
|
||||
applied server-side but client-of-Azure (substring on the JSON column), so the backend
|
||||
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
|
||||
never consume page budget.
|
||||
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
|
||||
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
|
||||
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
|
||||
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
|
||||
Safe because report blobs are readable only through the GA-only session-reports routes, and
|
||||
valuable because the ZIP outlives the session's retention/delete.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
|
||||
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: concept
|
||||
title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Activation
|
||||
|
||||
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
|
||||
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
|
||||
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
|
||||
30 s negative cache).
|
||||
|
||||
Two activation routes, one shared implementation
|
||||
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
|
||||
|
||||
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
|
||||
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
|
||||
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
|
||||
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
|
||||
Typical latency: ~1 minute after signup.
|
||||
|
||||
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
|
||||
arbitrates concurrent activations; exactly one caller runs the side effects
|
||||
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
|
||||
|
||||
## The welcome-mail race
|
||||
|
||||
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
|
||||
same table) — the only reliable address: it is typed by the user on the activation-pending
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
**Arbitration — write-then-read on both sides plus a send-once marker:**
|
||||
|
||||
* The approval path writes the `approved` row, then reads the address.
|
||||
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
|
||||
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
|
||||
activation for exactly this window).
|
||||
* Since both do write-then-read, at least one sees both halves and calls
|
||||
`TenantApprovalService.TrySendWelcomeEmailAsync`.
|
||||
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
|
||||
sends: at most one wins. The marker is consumed strictly **after** the address check —
|
||||
consuming it without an address would permanently suppress the mail.
|
||||
|
||||
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
|
||||
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
|
||||
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
notification-email save path"; user saves address at T+69 s → save path sees approved
|
||||
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
|
||||
* Address saved before manual approve (classic shape): approval path finds the address,
|
||||
wins the marker, sends; a later re-save of the same address does not re-send.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -23,24 +23,31 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
|
||||
|
||||
# Backend
|
||||
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
|
||||
# Web Portal
|
||||
|
||||
|
||||
+58
-1
@@ -1,6 +1,63 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-28
|
||||
## 2026-08-13
|
||||
|
||||
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
|
||||
|
||||
## 2026-08-12
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
|
||||
|
||||
## 2026-08-11 (later)
|
||||
|
||||
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
|
||||
|
||||
## 2026-08-10
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
|
||||
|
||||
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
|
||||
|
||||
## 2026-08-07 (later)
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
|
||||
|
||||
## 2026-08-06
|
||||
|
||||
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
|
||||
|
||||
## 2026-08-05 (later)
|
||||
|
||||
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
|
||||
|
||||
## 2026-08-05
|
||||
|
||||
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
|
||||
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
|
||||
|
||||
* **Update**: `url-registry.md` (+ `web/portal-navigation-prefetch.md` context) — the portal is now a **full static export** (`output: 'export'`, `trailingSlash: true`): the SWA serves plain files, the managed SSR runtime — the portal's last cold-start source ("Backend call failure" > 45 s) — no longer exists. Detail routes moved from path params to query strings (`/sessions?id=`, `/sessions/inspector?id=`, `/diagnosis?id=`, `/apps/detail?name=`, `/admin/backups/detail?id=`, `/admin/customs-archive/detail?tenantId=&rowKey=`) with all 21 link sites going through the new single-definition builders in `lib/routes.ts`; the 9 `[section]` families prerender via `generateStaticParams` server shells (37 pages, `dynamicParams=false`), server `redirect()` index pages became `ClientRedirect` shells. Legacy PATH-shaped deep links (sent Teams/Slack/webhook notifications hold `/sessions/{id}` forever) are preserved permanently: SWA wildcard rewrites serve the family page and the globally mounted `LegacyPathRedirect` rewrites to the canonical query URL (hash + query preserved; mapping pinned by `lib/__tests__/legacyPathRedirect.test.ts`). `middleware.ts` host routing became the client-side `HostRoutingGuard` (prefix list moved to `lib/hostRouting.ts`); the legacy `/go/[code]` route + `bootstrapValidation.ts` are deleted (BootstrapSessions table verified free of pre-cutover active sessions). `staticwebapp.config.json` now carries the 19 docs/marketing redirects (order-sensitive), the legacy rewrites, `404.html` override and ALL security headers incl. the literalized prod CSP — guarded by the new `swaConfig.guard.test.ts`; maintenance mode changed from whole-file swap to a first-routes-entry insert (README rewritten). Backend notification deep links go through the new `Constants.PortalSessionUrl` (query shape, pinned). Deploy: `deploy-web.yml` uploads `out/` with `is_static_export: true`.
|
||||
|
||||
* **Creation**: Added `backend/oobe-bootstrap-go-flow.md` (and a `Bootstrap go-URL` section + web-registry update in `url-registry.md`) — the OOBE bootstrap script endpoint moved from the portal's Next.js `/go/[code]` SSR route into the Function App (`GET /api/bootstrap/go/{code}`, `GetBootstrapScriptFunction`), unblocking the portal's static export; the customer URL became **`go.autopilotmonitor.com/{code}`** (Front Door custom domain, `/*` route with origin-path rewrite, caching disabled — the script inlines the bearer token). New `Constants.BootstrapGoBaseUrl` (single producer via `CreateBootstrapSessionFunction`) + web `BOOTSTRAP_GO_URL` (display only); both guard host lists extended. Security invariants ported to C#: `BootstrapScriptValueValidator` (exact-GUID tenantId/token, https + host-allowlist + `/agent/….zip` path, expiry ≤ 14 d) and `OobeBootstrapScriptGenerator` (Replace-token raw-string template, single-quoted PS literals, 200-char error cap before quote-doubling; always-HTTP-200 text/plain contract for `irm | iex`). Endpoint is `PublicAnonymous` in the catalog, `NoStoreCacheMiddleware`-covered, self-rate-limited `bootstrap-script:{ip}` 20/min (rate-limit reply is also a 200 error script). Tests: golden byte-parity against the TS template, validator invariants incl. hostile-URL cases, format-gate table, and the previously missing BootstrapUrl shape pin. Transition: the legacy www route + `middleware.ts` `/go` prefix stay alive for pre-migration URLs (validity ≤ 168 h) and are removed in the static-export PR no earlier than 168 h after the last www URL was issued. Customer docs `network-endpoints.md` gained the `go.` host (device-facing, Bootstrap-Sessions-only).
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — presentation layer for the startup error backfill (customer feedback: an unannounced wall of pre-agent Error events with a single identical timestamp read like a live failure). Agent: the backfill scan now buffers the capped record set, emits an Info summary event first (`modern_deployment_log`, `backfillSummary: true` + `replayedCount`/`droppedOverCap`/`lookbackMinutes`), then replays oldest-first so the timeline sequence follows event-log chronology. Web: `EventTimeline.tsx` badges `backfilled: true` events („⟲ Backfilled", tooltip) and renders the original `timeCreated` as the row time with a "(reported …)" suffix plus a `Recorded` metadata line — works retroactively for already-stored events. Severity is deliberately not downgraded.
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: concept
|
||||
title: MCP Tool Telemetry
|
||||
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
|
||||
tags: [mcp, telemetry, observability, tool-quality]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Three layers answer "are the MCP tools doing their job, and are tools missing?":
|
||||
|
||||
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
|
||||
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
|
||||
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
|
||||
`RequestTelemetryMiddleware` copies it into the request telemetry as
|
||||
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
|
||||
rates by KQL. Blind spot: tools that never call the backend, and handler-level
|
||||
soft errors (see layer 3).
|
||||
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
|
||||
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
|
||||
tool. Counts only, every call counts as success.
|
||||
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
|
||||
Logs) — the layer that carries what the other two structurally cannot:
|
||||
- `tool_call`: duration, `isError` **including soft errors** (handlers never
|
||||
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
|
||||
records those calls as successes), `resultChars` + `overCap` (result
|
||||
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
|
||||
it; a frequently-overCap tool needs tighter defaults or projections), a
|
||||
size-bounded args summary, and the caller scope (ga/delegated/tenant).
|
||||
This is the ONLY telemetry for backend-free tools: `search_docs`,
|
||||
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
|
||||
- `search_zero_hit`: query text (capped) whenever `search_events` /
|
||||
`search_knowledge` / `search_docs` return zero results — the most direct
|
||||
"missing docs / missing knowledge / missing tool?" demand signal.
|
||||
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
|
||||
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
|
||||
both found by live verification: SDK 1.30 wraps these McpErrors as a
|
||||
*successful* JSON-RPC response whose result is `{ isError: true, text:
|
||||
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
|
||||
them — and the Hono bridge (`getRequestListener`) may stream the body as
|
||||
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
|
||||
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
|
||||
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
|
||||
Handler soft errors never carry the `MCP error` prefix, so they are not
|
||||
double-logged. This is the strongest "the schema or description confuses
|
||||
the model" signal and was previously invisible everywhere.
|
||||
|
||||
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
|
||||
state — the template is not routinely deployed; the live flag is set with
|
||||
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
|
||||
|
||||
# Examples
|
||||
|
||||
Count zero-hit queries to find unmet demand (Log Analytics):
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"search_zero_hit"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize count() by tostring(d.tool), tostring(d.query)
|
||||
```
|
||||
|
||||
Per-tool soft-error and truncation rates:
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"tool_call"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize calls = count(), errors = countif(d.isError == true),
|
||||
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
|
||||
by tool = tostring(d.tool)
|
||||
```
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
|
||||
@@ -7,14 +7,14 @@ description: >-
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-07-22
|
||||
timestamp: 2026-08-11
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -27,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
||||
| --- | --- | --- |
|
||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
@@ -44,7 +44,12 @@ but the hard blocks must hold regardless of configuration:
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
|
||||
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
|
||||
test project) pins the JSON mirror to the constants so neither side can drift. The
|
||||
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
|
||||
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
|
||||
early-return — so a rule the agent would refuse is never shown as "Allowed".
|
||||
|
||||
# Examples
|
||||
|
||||
@@ -71,6 +76,15 @@ Two collectors were added to this contract on 2026-07-22 after shipping without
|
||||
remote config. The operator using it is already a local admin, so it relaxes the
|
||||
allowlist exactly the way unrestricted mode does — hard blocks still apply.
|
||||
|
||||
**WMI property projections (2026-08):** `SELECT BatteryStatus FROM Win32_Battery` is
|
||||
allowed because `SELECT * FROM Win32_Battery` is — a projection exposes a strict subset
|
||||
of the allowed class. The matcher parses the query shape instead of literal-prefix
|
||||
matching; the allowed-class set is derived at load time from the existing
|
||||
`wmiQueryPrefixes` entries, so `rules/guardrails.json` needs no new key. The same
|
||||
algorithm is mirrored in the portal (`utils/guardValidation.ts`) and the MCP server
|
||||
(`rule-validation.ts`); agents older than the change still block projections with a
|
||||
`security_warning` until they self-update.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- backend
|
||||
- gather-rules
|
||||
- config
|
||||
timestamp: 2026-07-20T00:00:00+02:00
|
||||
timestamp: 2026-08-04T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -64,6 +64,35 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
|
||||
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
|
||||
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
|
||||
|
||||
# Which "phase" feeds the triggers — the emitted timeline, not raw signals
|
||||
|
||||
The phase the executor sees is fed by `GatherRuleExecutorHost` from the **post-reduce
|
||||
`TimelineEventStream`**: `EventTimelineEmitter` publishes every emitted event's
|
||||
`(eventType, phase)` after the decision engine reduced the originating signal. Phase triggers
|
||||
therefore fire exactly when the UI timeline shows the phase — by construction.
|
||||
|
||||
This replaced the original V2 wiring (`SignalIngress.SignalPosted`), which fires at enqueue
|
||||
time with the **raw** collector payload. The raw phase can run minutes ahead of the
|
||||
engine-declared one: in session `32312a32` (RealmJoin tenant) the Shell-Core ESP-exit
|
||||
synthesized `EspPhaseChanged(FinalizingSetup)` at 12:18, while the engine — holding the
|
||||
transition behind the RealmJoin completion gate until the first deployment resolved — declared
|
||||
`phase_transition(FinalizingSetup)` at 12:25. A `phase_change` rule on `FinalizingSetup`
|
||||
reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` fired 7 minutes early and read the key before
|
||||
the RealmJoin package wrote it (`exists=false`).
|
||||
|
||||
Consequences of the emitted-event feed:
|
||||
|
||||
* `phase_change` / `phase_exit` / phase **scope** gates all follow the engine-reduced phase
|
||||
(single feed, consistent with the portal's "collects once when the enrollment reaches …").
|
||||
* `on_event` now sees **every emitted event type**, including engine-emitted ones
|
||||
(`enrollment_complete`, `phase_transition`, `realmjoin_resolved`, …) that never existed on
|
||||
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
|
||||
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
|
||||
emitted `EnrollmentEvent`).
|
||||
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
|
||||
off the raw signal stream deliberately — their collections are about OS state at the raw
|
||||
boundary (ESP exit, keep-awake), not about timeline agreement.
|
||||
|
||||
# Scope semantics (agent, `GatherRuleExecutor`)
|
||||
|
||||
* Scope applies to **all trigger types**:
|
||||
@@ -130,7 +159,9 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs` — scope gates, latch, canonical hash
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/GatherRuleExecutorHost.cs` — timeline-event trigger feed
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/TimelineEventStream.cs` — post-reduce observer surface
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Rule Authoring Surface — MCP Resources, validate_rule, and the Analyze Dry-Run
|
||||
description: >-
|
||||
How customer AIs author gather/analyze rules against the real contract: the baked
|
||||
authoring resources (guide, schemas, guardrails) generated by combine.js, the
|
||||
MCP-local validate_rule pre-flight, and the side-effect-free backend dry-run that
|
||||
traces a draft analyze rule against one session's events.
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts
|
||||
tags: [rules, mcp, analyze-rules, gather-rules, dry-run, authoring]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
Customers author rules with an AI connected to the MCP server. Retrieval
|
||||
(`search_docs`) is deliberately NOT the knowledge source for authoring: chunked
|
||||
top-K retrieval can silently omit an operator, a guardrail prefix, or an enum,
|
||||
and the AI then produces a plausible-but-dead rule. The authoring surface is
|
||||
therefore **deterministic**: three `get_resource` entries carry the complete
|
||||
contract, a local validator rejects what production would reject or silently
|
||||
ignore, and a backend dry-run evaluates a draft against real session events with
|
||||
a full trace.
|
||||
|
||||
The intended loop: draft → `validate_rule` → `test_analyze_rule` against a real
|
||||
session (one that should fire, one that should not) → iterate → create in the
|
||||
portal. The `author-rule` MCP prompt walks a client through exactly this order.
|
||||
|
||||
# Schema
|
||||
|
||||
## Resources (deterministic authoring context)
|
||||
|
||||
| Resource | Content | Source |
|
||||
| --- | --- | --- |
|
||||
| `rule_authoring_guide` | Hand-written complete authoring knowledge: collector types, triggers, condition sources, operators, confidence model incl. the exact factor DSL, `{{token}}` interpolation order, guardrail matching semantics, pitfalls | `src/rule-authoring-guide.ts` |
|
||||
| `rule_schemas` | Both JSON Schemas (2020-12) verbatim | generated |
|
||||
| `rule_guardrails` | `rules/guardrails.json` verbatim | generated |
|
||||
|
||||
Generation: `node rules/scripts/combine.js` bakes schemas + guardrails into
|
||||
`src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts` (same
|
||||
pattern as the web's `utils/guardrails.generated.ts`; the tsconfig `rootDir` and
|
||||
the Docker build's exclusion of `rules/schema` rule out importing the JSON
|
||||
directly). Drift is closed twice: CI's `guardrails-in-sync` job re-runs
|
||||
combine.js and fails on any diff, and `rule-authoring-drift.test.ts` pins
|
||||
generated == repo source plus guide enums == schema enums.
|
||||
|
||||
## validate_rule (MCP-local)
|
||||
|
||||
`src/rule-validation.ts`, no backend call. Three layers:
|
||||
|
||||
1. **Schema** — ajv (draft 2020-12) against the baked schemas, validating the
|
||||
single-rule `$defs` entry directly (the top-level oneOf wrapper would double
|
||||
every error).
|
||||
2. **Guardrails** — mirrors the AGENT's matching semantics from
|
||||
`GatherRuleGuards.cs` (see [gather-rule-guardrails.md](gather-rule-guardrails.md)):
|
||||
hive-stripped segment-bounded registry/file prefixes, whitespace-bounded WMI
|
||||
prefixes, EXACT command matching, `/`-bounded event-log channels, and the
|
||||
code-enforced hard blocks (`Security` etc. channels, `C:\Users`,
|
||||
`C:\Windows\System32\config`).
|
||||
3. **Semantic lint** — schema-valid-but-dead rules: unreachable
|
||||
confidenceThreshold, factor conditions outside the three parseable shapes
|
||||
(`exists`, `count >= N`, `phase_duration > N`), unknown event types (warning;
|
||||
`gather_*` softened to info), unanchored `not_regex` allow-lists,
|
||||
unresolvable `{{token}}`s, trigger cross-fields, outputEventType
|
||||
convention/collision checks.
|
||||
|
||||
The validator is advisory pre-flight only — the agent re-enforces guardrails
|
||||
on-device and the backend re-validates structure. It must never be stricter
|
||||
than production.
|
||||
|
||||
## POST /api/rules/analyze/dryrun (backend)
|
||||
|
||||
`DryRunAnalyzeRuleFunction` → `RuleEngine.DryRunRuleAsync`. Body
|
||||
`{ sessionId, rule }` (draft `AnalyzeRule`, 1 MB cap). Policy tier
|
||||
`TenantAdminOrGlobalReader` — a POST by shape but a pure read in effect, so the
|
||||
read-only Global Reader is admitted; platform-scope callers reach any tenant's
|
||||
session via `FindSessionTenantIdAsync` (same pattern as `GetRuleResultsFunction`).
|
||||
|
||||
`RuleEngine.DryRun.cs` is a partial-class trace twin of `EvaluateRule`: it
|
||||
shares the exact evaluators (`EvaluatePrecondition` / `EvaluateCondition` /
|
||||
`EvaluateConfidenceFactor`) but evaluates **all** conditions instead of breaking
|
||||
on the first failed required one — the full picture is the point of a dry run.
|
||||
Verdicts: `fired`, `skipped_by_precondition`, `required_condition_not_met`,
|
||||
`no_conditions_matched`, `below_confidence_threshold`, `no_events`. Each
|
||||
condition entry carries `matched`, the production evidence dictionary (or the
|
||||
evaluator's reason string) and `matchingEventCount` — how many session events
|
||||
even have that eventType, the first thing to check on an unexpected miss.
|
||||
|
||||
Invariants, pinned by `RuleEngineDryRunTests`:
|
||||
|
||||
* **Parity**: verdict `fired` ⇔ `EvaluateRule` produces a result, with identical
|
||||
confidence and evidence keys. Factor evaluation stays sequential against the
|
||||
accumulating `matchedConditions` (a factor can observe earlier `factor_*`
|
||||
entries) — reordering would change results.
|
||||
* **Side-effect freedom**: exactly one strict event read; never a RuleResult
|
||||
write, never `TryMarkSessionFailedFromRuleAsync`, never a notification.
|
||||
`wouldMarkSessionAsFailed` is reported, not acted on.
|
||||
|
||||
`test_analyze_rule` (MCP) wraps the endpoint and adds the interpolated
|
||||
explanation/remediation preview via the existing `interpolate-rule-template.ts`
|
||||
against the returned `matchedConditions`.
|
||||
|
||||
## POST /api/rules/gather/test-pattern (backend)
|
||||
|
||||
The logparser counterpart to the analyze dry-run, added after a field incident:
|
||||
a customer verified a logparser regex with a PHP tester and the agent's .NET
|
||||
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
|
||||
pasted sample lines with the AGENT's exact matching semantics:
|
||||
|
||||
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
|
||||
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
|
||||
regex conditions, which run IgnoreCase).
|
||||
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
|
||||
each line via the shared `CmTraceLogParser` and matches against the parsed
|
||||
MESSAGE only; `text` matches the raw line. First match per line
|
||||
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
|
||||
returned `groups` are exactly the event-data fields the device would emit.
|
||||
* Mirrors the agent's diagnostics: per-line outcomes
|
||||
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
|
||||
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
|
||||
zero matches.
|
||||
* Pure compute on the request body — no tenant data touched. Same policy tier
|
||||
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
|
||||
1 MB body.
|
||||
|
||||
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
|
||||
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
|
||||
so agent and backend run the SAME parsing implementation. Timestamp caveat:
|
||||
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
|
||||
server's timezone — the endpoint therefore reports match/parse results, never
|
||||
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
|
||||
additionally lints logparser parameters (missing/non-compiling pattern, unknown
|
||||
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
|
||||
session dry-run is analyze-only. Logparser authors get the pattern tester
|
||||
above; all gather authors get guardrail pre-flight plus the on-device debug
|
||||
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
|
||||
* **No rule write/deploy via MCP** — creating rules stays in the portal.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
|
||||
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts` — validate_rule / test_analyze_rule
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts` — guide resource
|
||||
* `rules/scripts/combine.js` — MCP module generation
|
||||
+37
-1
@@ -21,7 +21,7 @@ literal appears nowhere else.
|
||||
| Component | Registry | Guard test |
|
||||
|---|---|---|
|
||||
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
|
||||
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
|
||||
|
||||
Derived registries build on these instead of repeating hosts:
|
||||
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
|
||||
`build-agent.yml` — they exist precisely to keep already-deployed customer
|
||||
bootstrap scripts working until the customer migration completes.
|
||||
|
||||
# Bootstrap go-URL (2026-07-28)
|
||||
|
||||
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
|
||||
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
|
||||
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
|
||||
Function App (`GetBootstrapScriptFunction`). Registry entries:
|
||||
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
|
||||
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
|
||||
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
|
||||
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
|
||||
independent oracle — before this, no test guarded the bootstrap URL shape at
|
||||
all.
|
||||
|
||||
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
|
||||
the `/agent/*` route above, sharpened: the script body inlines the bearer
|
||||
token, so a cache hit would replay one requester's token to another.
|
||||
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
|
||||
`/api/bootstrap/go/` prefix.
|
||||
|
||||
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
|
||||
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
|
||||
`middleware.ts` are deleted — verified safe because the BootstrapSessions
|
||||
table held zero active sessions predating the URL cutover (all expired
|
||||
2026-03). The backend is now the only script producer;
|
||||
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
|
||||
shipped template.
|
||||
|
||||
Portal detail routes are query-string based since the same PR
|
||||
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
|
||||
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
|
||||
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
|
||||
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
|
||||
carries the production redirects/headers and is guarded by
|
||||
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
|
||||
unsafe-eval, route order).
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
|
||||
|
||||
@@ -94,6 +94,12 @@ Do not benchmark this with a synthetic burst. Firing requests in quick successio
|
||||
and measures the best case: in a 25-round `curl --parallel-max 6` run, round 1 came in at p50 288 ms
|
||||
and rounds 4–25 at p50 113 ms.
|
||||
|
||||
> **Endgame (2026-07-29).** The cold-start class was eliminated at the root: the portal became a
|
||||
> full static export (`output: 'export'`) — the SWA-managed SSR runtime no longer exists, so there
|
||||
> is nothing left to cool down. One escalation drove this: on 2026-07-28 a cold start exceeded the
|
||||
> SWA's 45-second function limit and the landing page served a hard "Backend call failure" 500.
|
||||
> The availability test that bridged the gap as a keep-alive stays as plain uptime monitoring.
|
||||
|
||||
# Diagnosing this class of problem
|
||||
|
||||
The backend Application Insights resource is the wrong place to look — a freeze appears there as an
|
||||
|
||||
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
|
||||
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
|
||||
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
|
||||
param oAuthStateSigningKey string = ''
|
||||
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
|
||||
param toolLoggingEnabled bool = false
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
|
||||
param toolLoggingEnabled bool = true
|
||||
|
||||
// --- Azure Container Registry ---
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
|
||||
}
|
||||
+87
@@ -2687,6 +2687,93 @@
|
||||
"truncated",
|
||||
"coverage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"firmware",
|
||||
"uefi",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+23
-2
@@ -250,6 +250,27 @@
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
]
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"collectorType": {
|
||||
"type": "string",
|
||||
"description": "Type of data collection.",
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser"]
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "json", "xml", "command_allowlisted", "logparser"]
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
|
||||
@@ -154,6 +154,12 @@ ${flatArray(flatEventLogChannels)}
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedEventLogChannels)}
|
||||
];
|
||||
|
||||
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedCommandPatterns)}
|
||||
];
|
||||
|
||||
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
||||
@@ -161,3 +167,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
|
||||
} else {
|
||||
console.warn('guardrails.json not found — skipping guardrails generation');
|
||||
}
|
||||
|
||||
// ── MCP rule-authoring module generation ────────────────────────────────────
|
||||
// The MCP server exposes the rule schemas + guardrails as get_resource content
|
||||
// so a customer's AI can author rules against the real contract instead of
|
||||
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
|
||||
// and the Docker build (rules/schema not copied) rule out importing the JSON
|
||||
// directly — so we bake it into a TS module, exactly like the web mirror above.
|
||||
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
|
||||
// makes drift between rules/* and this generated module impossible to merge.
|
||||
|
||||
const mcpAuthoringOutput = path.resolve(
|
||||
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
|
||||
);
|
||||
|
||||
{
|
||||
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
||||
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
|
||||
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
|
||||
|
||||
const ts = `/**
|
||||
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
|
||||
* Run: node rules/scripts/combine.js
|
||||
*
|
||||
* Consumed by the MCP server's rule-authoring surface (get_resource +
|
||||
* validate_rule): the JSON Schemas are the validation contract, the guardrails
|
||||
* are the agent-side collection allowlists. Single source of truth is rules/;
|
||||
* the CI guardrails-in-sync job guards this file against drift.
|
||||
*/
|
||||
|
||||
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
|
||||
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
|
||||
|
||||
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
|
||||
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
|
||||
|
||||
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
|
||||
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
|
||||
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
|
||||
}
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
An OOBE-relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Windows Backup for Organizations breaks that
|
||||
by restoring settings and creating ONE profile while still in OOBE. We relax guards
|
||||
2+3 ONLY when all three positives hold, and nothing else:
|
||||
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
|
||||
(b) there is exactly ONE real user profile, AND
|
||||
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear during OOBE for the
|
||||
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
|
||||
older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Positive OOBE check via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
|
||||
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
|
||||
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
|
||||
$oobeRelax = $false
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
|
||||
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
|
||||
} elseif ($profilePaths.Count -gt 1) {
|
||||
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the OOBE-relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
# registry, filesystem, WinRT) is mocked; profile directories are real folders
|
||||
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
New-Item -ItemType Directory -Path $p -Force | Out-Null
|
||||
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
|
||||
return $p
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
|
||||
# the Windows365 registry key AND the CloudManagedDesktopExtension service
|
||||
# (both required).
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
|
||||
}
|
||||
|
||||
It 'detects a Cloud PC when both markers are present' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect with the service alone' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect a plain device (no markers)' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'still evaluates markers when the WMI identity query fails' {
|
||||
Mock Get-CimInstance { throw 'RPC server unavailable' }
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-OobeState' {
|
||||
It 'returns a non-empty state string and never throws' {
|
||||
Get-OobeState | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RealUserProfilePaths' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
}
|
||||
|
||||
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
|
||||
Mock Get-CimInstance {
|
||||
@(
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
|
||||
)
|
||||
}
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\UserOne')
|
||||
}
|
||||
|
||||
It 'falls back to the filesystem view when the WMI query fails' {
|
||||
Mock Get-CimInstance { throw 'WMI broken' }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Default' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\SomeUser')
|
||||
}
|
||||
|
||||
It 'returns an empty result when only special/system profiles exist' {
|
||||
Mock Get-CimInstance { @() }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
|
||||
)
|
||||
}
|
||||
@(Get-RealUserProfilePaths).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RelaxDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Defaults: physical device, OOBE long done. Individual tests override.
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
}
|
||||
|
||||
Context 'Windows 365 Cloud PC' {
|
||||
BeforeEach {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
}
|
||||
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
|
||||
$p = New-FakeProfile 'CloudPcUser' 0.6
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
$d.IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for an old profile (productive Cloud PC)' {
|
||||
$p = New-FakeProfile 'CloudPcUser' 30
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not relax for two profiles' {
|
||||
$p1 = New-FakeProfile 'UserA' 2
|
||||
$p2 = New-FakeProfile 'UserB' 400
|
||||
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
|
||||
Mock Get-OobeState { 'Unavailable' }
|
||||
$p = New-FakeProfile 'CpcUser' 1
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for a profile with a future creation time (clock skew)' {
|
||||
$p = New-FakeProfile 'SkewUser' -10
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations)' {
|
||||
It 'relaxes for a single fresh profile during OOBE' {
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
$p = New-FakeProfile 'RestoreUser' 3
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Context 'no trigger' {
|
||||
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
|
||||
$p = New-FakeProfile 'SomeUser' 2
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'stays inactive with no profiles at all' {
|
||||
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-BootstrapDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Baseline: clean device during Device ESP -- no marker, no profiles, no
|
||||
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
|
||||
Mock Get-ItemProperty { $null }
|
||||
Mock Get-RealUserProfilePaths { @() }
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
|
||||
}
|
||||
|
||||
It 'installs on a clean ESP device' {
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.ReasonCode | Should -BeNullOrEmpty
|
||||
$d.RelaxActive | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'skips when the Deployed marker exists (guard 1)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
}
|
||||
|
||||
It 'skips a productive device with an old profile (guard 2)' {
|
||||
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
|
||||
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
# First-connect user is already visible in LogonUI on W365 -- must not block either.
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when a real user has logged on and no relax applies (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
|
||||
}
|
||||
|
||||
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
|
||||
# W365 reality: provisioned days ago, running headless ever since; the user's
|
||||
# first connect just created the profile. Uptime is meaningless here.
|
||||
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
|
||||
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'skips when the agent binary is already present (guard 5)' {
|
||||
$binPath = Join-Path $TestDrive 'AgentBinExisting'
|
||||
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AgentPresent'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'logging purity' {
|
||||
# Regression guard: Write-Log runs inside functions that RETURN decision
|
||||
# objects. If it ever emits to the output stream again (Write-Output), the
|
||||
# log lines leak into the return values and turn them into arrays. This
|
||||
# test deliberately does NOT mock Write-Log.
|
||||
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
|
||||
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
|
||||
# the ProgramData path defined by the dot-sourced script.
|
||||
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
|
||||
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
|
||||
$d.Count | Should -Be 1
|
||||
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,42 @@
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs five pre-flight guards to skip productive devices and ghost re-installs
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest
|
||||
(legacy fallback: Content-MD5 header from the blob response)
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress.
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OOBE is never InProgress when
|
||||
this script runs. The assigned user's profile is created at first connect,
|
||||
typically seconds before Intune pulls this script. Trigger: the device
|
||||
positively identifies as a Cloud PC via TWO local markers (both required):
|
||||
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
|
||||
(SKIP-safe: we never install on uncertainty). On productive devices the
|
||||
profile is old, so (b) keeps skipping correctly.
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production blob; override only
|
||||
for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
@@ -26,6 +50,14 @@
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
|
||||
days between provisioning and the user's first connect, so the fresh-profile
|
||||
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> bootstrap gap is always
|
||||
minutes; an older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
@@ -33,10 +65,36 @@
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- Guard/relax logic lives in functions; the script only executes when run
|
||||
directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
InProgress; the single-fresh-profile conditions are unchanged
|
||||
and guard 4 (uptime window) is exempted for Cloud PCs.
|
||||
Raw OOBE state and Cloud PC marker states are now logged.
|
||||
Guard/relax logic refactored into functions with a dot-source
|
||||
entry guard so the Pester suite can test it without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -55,17 +113,20 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0"
|
||||
$ScriptVersion = "2.3"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
@@ -73,88 +134,185 @@ $AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
|
||||
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
|
||||
# so identification uses two W365-specific markers instead. BOTH are required (AND):
|
||||
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
|
||||
# agents). Identity and marker states are logged for diagnosis; any error returns
|
||||
# $false (SKIP-safe).
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
|
||||
}
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
|
||||
|
||||
$relaxActive = $false
|
||||
$oobeState = Get-OobeState
|
||||
$oobeInProgress = ($oobeState -eq 'InProgress')
|
||||
$isCloudPc = Test-IsCloudPc
|
||||
if ($isCloudPc) {
|
||||
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
|
||||
}
|
||||
|
||||
$relaxTrigger = $null
|
||||
if ($oobeInProgress) {
|
||||
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
|
||||
} elseif ($isCloudPc) {
|
||||
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
|
||||
}
|
||||
|
||||
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$relaxActive = $true
|
||||
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($ProfilePaths.Count -eq 1) {
|
||||
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
|
||||
} elseif ($ProfilePaths.Count -gt 1) {
|
||||
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Active = $relaxActive
|
||||
OobeState = $oobeState
|
||||
IsCloudPc = $isCloudPc
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
)
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard).
|
||||
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
$profileNames = @(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
exit 0
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet.
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
|
||||
# provisioning until the user's first connect (often days), so boot uptime says
|
||||
# nothing there -- the fresh-profile window of the relax is the time anchor.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
if ($relax.Active -and $relax.IsCloudPc) {
|
||||
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
|
||||
} else {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
@@ -162,151 +320,175 @@ try {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
try {
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
if (-not $decision.Install) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "Manifest has no sha256 field - falling back to legacy MD5 check (older build)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
if ($expectedSha256) {
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
}
|
||||
else {
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
exit 0
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test (DEV)
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor-dev.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
function Write-Step {
|
||||
param(
|
||||
[string]$Status,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
$prefix = switch ($Status) {
|
||||
'PASS' { '[PASS]' }
|
||||
'SKIP' { '[SKIP]' }
|
||||
'WARN' { '[WARN]' }
|
||||
'INFO' { '[INFO]' }
|
||||
default { '[....]' }
|
||||
}
|
||||
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
$wouldInstall = $true
|
||||
$reasons = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run, DEV) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Previously deployed marker exists: $deployed")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
try {
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Result ==='
|
||||
Write-Host ''
|
||||
|
||||
if ($wouldInstall) {
|
||||
Write-Host '[DECISION] WOULD INSTALL agent on this device.'
|
||||
} else {
|
||||
Write-Host '[DECISION] WOULD SKIP agent installation on this device.'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Summary ==='
|
||||
|
||||
if ($reasons.Count -eq 0) {
|
||||
Write-Host 'No blocking reasons found.'
|
||||
} else {
|
||||
foreach ($reason in $reasons) {
|
||||
Write-Host " - $reason"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
Write-Host "UptimeHours : $([string]$uptimeHours)"
|
||||
Write-Host "AgentExePath : $AgentExePath"
|
||||
Write-Host ''
|
||||
@@ -1,8 +1,13 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
|
||||
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
|
||||
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active.
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
@@ -38,6 +43,25 @@ function Get-RegistryValueSafe {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Test-OobeInProgress {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
|
||||
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
|
||||
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
@@ -58,63 +82,82 @@ if ($deployed) {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 1: No previous deployment marker found.'
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard)
|
||||
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
# Collect real (non-special) user profiles as full paths (combined WMI/filesystem view).
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
$wmiProfileQueryFailed = $false
|
||||
|
||||
$profileNames = @(
|
||||
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object {
|
||||
-not $_.Special -and
|
||||
$_.LocalPath -like 'C:\Users\*'
|
||||
} |
|
||||
ForEach-Object {
|
||||
Split-Path $_.LocalPath -Leaf
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
|
||||
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
|
||||
# into the string and return its first char ('C') instead of the full path.
|
||||
$profilePaths = @(
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
}
|
||||
catch {
|
||||
$wmiProfileQueryFailed = $true
|
||||
}
|
||||
|
||||
# Filesystem view -- catches profiles WMI might miss
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) |
|
||||
Where-Object {
|
||||
$_ -and $_ -notmatch $excludePattern
|
||||
} |
|
||||
Select-Object -Unique
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) |
|
||||
Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
if ($wmiProfileQueryFailed) {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 2: WMI profile query failed, filesystem check still applied.'
|
||||
Write-Step -Status 'WARN' -Message 'Profiles: WMI query failed, filesystem check still applied.'
|
||||
}
|
||||
|
||||
if ($profileNames) {
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers -- OOBE InProgress (Windows
|
||||
# Backup for Organizations restore) or Cloud PC identity (headless provisioning,
|
||||
# profile at first connect). Either additionally requires exactly one fresh profile.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
if ($profilePaths.Count -eq 1) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if (($oobeInProgress -or $isCloudPc) -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "Cloud PC markers: Windows365 registry key = $cloudPcRegMarker ; CloudManagedDesktopExtension service = $cloudPcSvcMarker ; IsCloudPc = $isCloudPc"
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'SKIP' -Message "Guard 2: Real user profile(s) found ($names). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
$trigger = if ($oobeInProgress) { 'OOBE InProgress' } else { 'Cloud PC' }
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but relax active ($trigger + single fresh profile) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check
|
||||
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
|
||||
# This is "how old can the OOBE state be before I no longer trust it for initial install".
|
||||
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
|
||||
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
|
||||
# Guard 4: Bootstrap window check (sleep/standby does NOT reset uptime, only real boot does)
|
||||
$lastBoot = $null
|
||||
$uptimeHours = $null
|
||||
|
||||
@@ -123,9 +166,15 @@ try {
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
if ($oobeRelax -and $isCloudPc) {
|
||||
# Cloud PCs run headless for days before the user's first connect; the
|
||||
# fresh-profile window is the time anchor there, not boot uptime.
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h (over ${MaxBootstrapWindowHours}h), but the Cloud PC relax is active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
}
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
@@ -134,7 +183,7 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 4: Agent binary already present
|
||||
# Guard 5: Agent binary already present
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
@@ -168,6 +217,14 @@ Write-Host ''
|
||||
Write-Host '=== Raw Values ==='
|
||||
Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "CloudPcRegistryMarker : $cloudPcRegMarker"
|
||||
Write-Host "CloudPcServiceMarker : $cloudPcSvcMarker"
|
||||
Write-Host "IsCloudPc : $isCloudPc"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "RelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
One or more high-level feature identifiers. The script translates each to the underlying
|
||||
Graph permissions. Use this when you don't want to know permission strings.
|
||||
Available features:
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- W365CloudPcValidation -> CloudPC.Read.All
|
||||
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
|
||||
.PARAMETER Permissions
|
||||
Microsoft Graph application permission names to grant directly. Use when the admin UI
|
||||
@@ -76,7 +80,7 @@ param(
|
||||
[string]$ClientId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
|
||||
[ValidateSet('ScriptDisplayNames')]
|
||||
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
|
||||
[string[]]$Features,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
|
||||
@@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop'
|
||||
# ---- Feature -> Graph permission catalog -----------------------------------
|
||||
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
|
||||
$FeatureCatalog = @{
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'W365CloudPcValidation' = @('CloudPC.Read.All')
|
||||
}
|
||||
|
||||
# Translate Features -> Permissions when caller used the high-level form.
|
||||
|
||||
@@ -94,6 +94,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_transliterates_typographic_punctuation_to_ascii()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
|
||||
// The log file is UTF-8 without BOM; cp1252-guessing viewers (Outlook web
|
||||
// preview) render em dashes as mojibake. Our own punctuation must come out
|
||||
// as plain ASCII, while genuine non-ASCII payload stays untouched.
|
||||
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
|
||||
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
|
||||
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_swallows_io_errors_silently()
|
||||
{
|
||||
|
||||
+43
@@ -308,9 +308,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
|
||||
[InlineData("SELECT Name FROM Win32_Process")] // projection of a disallowed class
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing via projection
|
||||
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
|
||||
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// WMI query matcher: "SELECT <* | property-list> FROM <allowed class>"
|
||||
//
|
||||
// Guard-level (no WMI touched): a property projection of an allowed class
|
||||
// exposes a strict subset of the already-allowed SELECT *, so it is admitted.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM Win32_Battery")] // legacy form unchanged
|
||||
[InlineData("SELECT * FROM Win32_BIOS WHERE PrimaryBIOS = TRUE")] // legacy + WHERE
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_Battery")]
|
||||
[InlineData("SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery")]
|
||||
[InlineData("select batterystatus from win32_battery")] // case-insensitive
|
||||
[InlineData("SELECT\tBatteryStatus\tFROM\tWin32_Battery")] // tabs as separators
|
||||
[InlineData(" SELECT BatteryStatus FROM Win32_Battery ")] // surrounding whitespace
|
||||
[InlineData("SELECT BatteryStatus,EstimatedChargeRemaining FROM Win32_Battery")] // no space after comma
|
||||
[InlineData("SELECT Status FROM Win32_Battery WHERE BatteryStatus = 1")] // projection + WHERE
|
||||
public void WmiGuard_ProjectionOfAllowedClass_IsAllowed(string query)
|
||||
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed(query));
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT Name FROM Win32_Process")] // class not on the allowlist
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing
|
||||
[InlineData("SELECT * FROM Win32_BatteryX")]
|
||||
[InlineData("SELECT *, Name FROM Win32_BIOS")] // star mixed into a property list
|
||||
[InlineData("SELECT FROM Win32_BIOS")] // empty property list
|
||||
[InlineData("SELECT BatteryStatus FROM")] // missing class
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_Battery;DROP")] // non-whitespace boundary after class
|
||||
[InlineData("SELECT BatteryStatus, FROM Win32_Battery")] // trailing comma
|
||||
[InlineData("")]
|
||||
public void WmiGuard_MalformedOrDisallowedQuery_IsBlocked(string query)
|
||||
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(query));
|
||||
|
||||
[Fact]
|
||||
public void WmiGuard_NullQuery_IsBlocked()
|
||||
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
|
||||
|
||||
[Fact]
|
||||
public void WmiGuard_UnrestrictedMode_AllowsAnyQuery()
|
||||
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed("SELECT Name FROM Win32_Process", unrestrictedMode: true));
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
|
||||
+18
@@ -138,6 +138,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
Assert.Equal(2, changed["suppressedPolls"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseChange_writes_transition_line()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { Rule("GATHER-PHASE-001") });
|
||||
_executor.WaitForStartupRules(30);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // repeat — no extra line
|
||||
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("- | phase | phase change: Unknown -> DeviceSetup (first phase signal", trace);
|
||||
Assert.Contains("- | phase | phase change: DeviceSetup -> AccountSetup", trace);
|
||||
// A repeated signal for the same phase must not produce a second transition line.
|
||||
var marker = "phase change: DeviceSetup -> AccountSetup";
|
||||
Assert.Equal(trace.IndexOf(marker), trace.LastIndexOf(marker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCollectorResult_is_traced_via_startup_execution()
|
||||
{
|
||||
|
||||
+48
@@ -108,6 +108,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
Assert.Contains("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_traces_line_number_matched_text_and_groups()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "match.log");
|
||||
File.WriteAllText(path, "boring line\nError 0x80070005 in setup\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule(path, @"Error (?<code>0x[0-9A-Fa-f]+)", format: "text"), _context);
|
||||
|
||||
Assert.Single(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("match.log: line 2 matched \"Error 0x80070005\"", trace);
|
||||
Assert.Contains("groups: code=\"0x80070005\"", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchTraces_are_capped_per_file()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "many.log");
|
||||
var sb = new System.Text.StringBuilder();
|
||||
for (var i = 1; i <= 15; i++)
|
||||
sb.Append("hit number ").Append(i).Append('\n');
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "hit", format: "text"), _context);
|
||||
|
||||
Assert.Equal(15, _events.Count); // all matches still emit events
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("many.log: line 10 matched", trace);
|
||||
Assert.Contains("further matches not traced individually", trace);
|
||||
Assert.DoesNotContain("many.log: line 11 matched", trace);
|
||||
Assert.Contains("matched 15", trace); // summary still carries the full count
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRegex_traces_the_pattern_itself()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "irrelevant.log");
|
||||
File.WriteAllText(path, "content\n");
|
||||
|
||||
new LogParserCollector().Execute(Rule(path, "([unclosed", format: "text"), _context);
|
||||
|
||||
Assert.Empty(_events);
|
||||
var trace = TraceContent();
|
||||
Assert.Contains("invalid regex pattern '([unclosed'", trace);
|
||||
Assert.Contains("rule can never match", trace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingFile_traces_no_files_matched()
|
||||
{
|
||||
|
||||
+23
-1
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
@@ -92,6 +92,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
|
||||
}
|
||||
|
||||
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
|
||||
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
|
||||
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
|
||||
{
|
||||
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
|
||||
// lines with an explicit bias. Without bias handling the line either failed to
|
||||
// match at all (old regex) or would be misread in agent-local time.
|
||||
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
|
||||
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
|
||||
}
|
||||
|
||||
// ── Oversized line → parses, no throw, no truncation of the message ───
|
||||
|
||||
[Fact]
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the display mirror in rules/guardrails.json (embedded into this assembly)
|
||||
/// to the agent's hard-block CONSTANTS in <see cref="GatherRuleGuards"/>.
|
||||
/// Enforcement is code-only by design (a JSON parse error must never lift a hard
|
||||
/// block); the JSON mirror exists so the portal and the MCP validator can
|
||||
/// pre-flight the same blocks. This test keeps the two from drifting: an addition
|
||||
/// on either side fails here until the other side follows.
|
||||
/// </summary>
|
||||
public sealed class GuardrailsParityTests
|
||||
{
|
||||
private static JObject LoadEmbeddedGuardrails()
|
||||
{
|
||||
var assembly = typeof(GatherRuleGuards).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json");
|
||||
Assert.NotNull(stream);
|
||||
using var reader = new StreamReader(stream!);
|
||||
return JObject.Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_file_prefixes_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedFilePrefixes"]!.ToObject<string[]>()!;
|
||||
var code = new[] { GatherRuleGuards.BlockedUsersPrefix }
|
||||
.Concat(GatherRuleGuards.AdditionalHardBlockedPathPrefixes);
|
||||
|
||||
Assert.Equal(
|
||||
code.Select(p => p.ToLowerInvariant()).OrderBy(p => p),
|
||||
json.Select(p => Path.GetFullPath(p).ToLowerInvariant()).OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_command_patterns_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedCommandPatterns"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedCommandPatterns.OrderBy(p => p),
|
||||
json.OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Max_command_length_matches_code_constant()
|
||||
{
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.MaxCommandLength,
|
||||
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_event_log_channels_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedEventLogChannels"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedEventLogChannels.OrderBy(c => c),
|
||||
json.OrderBy(c => c));
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -54,6 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
public ISignalIngressSink? CapturedIngress { get; private set; }
|
||||
public IClock? CapturedClock { get; private set; }
|
||||
public AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? CapturedTelemetrySpool { get; private set; }
|
||||
public TimelineEventStream? CapturedTimelineEvents { get; private set; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
@@ -62,12 +63,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
|
||||
CapturedIngress = ingress;
|
||||
CapturedClock = clock;
|
||||
CapturedTelemetrySpool = telemetrySpool;
|
||||
CapturedTimelineEvents = timelineEvents;
|
||||
foreach (var name in HostNames)
|
||||
{
|
||||
Hosts.Add(new FakeCollectorHost(name));
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
|
||||
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
|
||||
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
|
||||
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
|
||||
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
|
||||
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
|
||||
/// fires only when the phase declaration is actually emitted.
|
||||
/// Rules use the absent-registry-path pattern so no real system state is touched; execution
|
||||
/// is observed via the InformationalEvent posts the host sends through its ingress sink.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorHostTimelineFeedTests : IDisposable
|
||||
{
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_5b1f03a2-0000-0000-0000-000000000000";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly CapturingSink _sink = new CapturingSink();
|
||||
private readonly TimelineEventStream _stream = new TimelineEventStream();
|
||||
private GatherRuleExecutorHost? _host;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host?.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ISignalIngressSink
|
||||
{
|
||||
private readonly List<string> _eventTypes = new List<string>();
|
||||
private readonly object _gate = new object();
|
||||
|
||||
public int CountOf(string eventType)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var t in _eventTypes)
|
||||
if (string.Equals(t, eventType, StringComparison.Ordinal)) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public void Post(
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
string sourceOrigin,
|
||||
Evidence evidence,
|
||||
IReadOnlyDictionary<string, string>? payload = null,
|
||||
int kindSchemaVersion = 1,
|
||||
object? typedPayload = null)
|
||||
{
|
||||
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
|
||||
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)) return;
|
||||
lock (_gate) _eventTypes.Add(eventType);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildHost(params GatherRule[] rules)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_host = new GatherRuleExecutorHost(
|
||||
sessionId: "sess",
|
||||
tenantId: "tenant",
|
||||
ingress: _sink,
|
||||
clock: new SystemClock(),
|
||||
logger: logger,
|
||||
rules: new List<GatherRule>(rules),
|
||||
imeLogPathOverride: null,
|
||||
unrestrictedMode: true,
|
||||
gatherDebugLogPath: null,
|
||||
timelineEvents: _stream);
|
||||
_host.Start();
|
||||
}
|
||||
|
||||
private bool WaitForCount(string eventType, int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (_sink.CountOf(eventType) >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return _sink.CountOf(eventType) >= count;
|
||||
}
|
||||
|
||||
private static GatherRule Rule(string id, string trigger, string? triggerPhase = null, string? triggerEventType = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = trigger,
|
||||
TriggerPhase = triggerPhase ?? string.Empty,
|
||||
TriggerEventType = triggerEventType ?? string.Empty,
|
||||
OutputEventType = "gather_host_test",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PhaseChangeRule_FiresOnEmittedPhaseDeclaration_NotOnPhaselessEvents()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-001", "phase_change", triggerPhase: "FinalizingSetup"));
|
||||
|
||||
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
|
||||
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
|
||||
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// Only the engine's actual phase declaration fires the rule.
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventRule_FiresForEngineEmittedEventTypes()
|
||||
{
|
||||
// enrollment_complete is an engine-emitted effect event; it never existed on the raw
|
||||
// InformationalEvent signal stream, so the docs-promised "On Event with
|
||||
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
|
||||
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseFeed_DedupsConsecutiveSamePhaseDeclarations()
|
||||
{
|
||||
// "Any phase" phase_change rule fires on every phase TRANSITION — a re-declared
|
||||
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
|
||||
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseExitRule_FiresWhenEmittedTimelineLeavesThePhase()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoppedHost_NoLongerReactsToTheStream()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_host!.Stop();
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
|
||||
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
|
||||
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
|
||||
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
|
||||
/// contract (bootstrap must continue on any updater failure). Uses the
|
||||
/// <see cref="AgentBootstrap.UpdateInvokerOverride"/> seam — no network, no process
|
||||
/// side effects.
|
||||
/// <para>
|
||||
/// Static-hook state: each test saves/restores both statics in ctor/Dispose so
|
||||
/// parallel-running test classes never observe a leaked value (idiom of
|
||||
/// SelfUpdaterGracefulShutdownTests).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AgentBootstrapSelfUpdateRetryTests : IDisposable
|
||||
{
|
||||
private readonly AgentBootstrap.SelfUpdateInvoker _savedInvoker;
|
||||
private readonly bool _savedVersionCheckFlag;
|
||||
|
||||
public AgentBootstrapSelfUpdateRetryTests()
|
||||
{
|
||||
_savedInvoker = AgentBootstrap.UpdateInvokerOverride;
|
||||
_savedVersionCheckFlag = SelfUpdater.LastVersionCheckSucceeded;
|
||||
AgentBootstrap.UpdateInvokerOverride = null;
|
||||
SelfUpdater.LastVersionCheckSucceeded = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AgentBootstrap.UpdateInvokerOverride = _savedInvoker;
|
||||
SelfUpdater.LastVersionCheckSucceeded = _savedVersionCheckFlag;
|
||||
}
|
||||
|
||||
private static AgentLogger NewLogger(string path)
|
||||
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
|
||||
|
||||
[Fact]
|
||||
public void Skips_retry_when_startup_version_check_already_succeeded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var invoked = false;
|
||||
SelfUpdater.LastVersionCheckSucceeded = true;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
invoked = true;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false);
|
||||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retries_with_await_enrollment_trigger_and_relaxed_timeouts()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
string? capturedVersion = null, capturedDir = null, capturedReason = null;
|
||||
bool? capturedConsole = null, capturedDowngrade = null;
|
||||
int capturedDownloadMs = 0, capturedVersionCheckMs = 0;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
capturedVersion = v;
|
||||
capturedDir = dir;
|
||||
capturedConsole = console;
|
||||
capturedReason = reason;
|
||||
capturedDownloadMs = dl;
|
||||
capturedDowngrade = downgrade;
|
||||
capturedVersionCheckMs = vc;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: true);
|
||||
|
||||
Assert.Equal("await_enrollment", capturedReason);
|
||||
// Relaxed vs. the boot-speed-tuned startup check (10s/2.5s) — enrollment is
|
||||
// already past OOBE's critical path here.
|
||||
Assert.Equal(60000, capturedDownloadMs);
|
||||
Assert.Equal(10000, capturedVersionCheckMs);
|
||||
// No cached remote config on a fresh --await-enrollment install → no downgrade override.
|
||||
Assert.False(capturedDowngrade);
|
||||
Assert.True(capturedConsole);
|
||||
Assert.False(string.IsNullOrEmpty(capturedVersion));
|
||||
Assert.False(string.IsNullOrEmpty(capturedDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Synchronous_invoker_exception_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride =
|
||||
(v, dir, console, reason, dl, downgrade, vc) => throw new InvalidOperationException("boom");
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Faulted_update_task_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
Task.FromException(new IOException("download interrupted"));
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_install_without_wait_arg_uses_agent_default_of_600()
|
||||
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
|
||||
{
|
||||
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
|
||||
// and there is no persisted config yet. Agent owns the default (600 s).
|
||||
// and there is no persisted config yet. "Not configured" is persisted as null
|
||||
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
|
||||
// a number into the file would decouple deployed devices from default changes.
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing: null,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
Assert.Null(merged.BootstrapToken);
|
||||
Assert.Null(merged.TenantId);
|
||||
Assert.Equal(600, merged.TenantIdWaitSeconds);
|
||||
Assert.Null(merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_config_without_wait_field_treated_as_zero()
|
||||
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
|
||||
{
|
||||
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
|
||||
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
|
||||
// overrides cleanly; no extra migration logic required.
|
||||
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
|
||||
// property → nullable deserialisation yields null. A reinstall that does not
|
||||
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
|
||||
// disable the wait); null carries through and the runtime default applies.
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = 0,
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
tenantIdArg: null!, tenantIdGiven: false,
|
||||
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
|
||||
|
||||
Assert.Equal("tok-legacy", merged.BootstrapToken);
|
||||
Assert.Equal("tenant-legacy", merged.TenantId);
|
||||
Assert.Null(merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
|
||||
{
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("tenant-legacy", merged.TenantId);
|
||||
Assert.Equal(600, merged.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
|
||||
{
|
||||
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
|
||||
// wait by a later redeploy that omits the flag.
|
||||
var existing = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-abc",
|
||||
TenantId = "tenant-xyz",
|
||||
TenantIdWaitSeconds = 0,
|
||||
};
|
||||
|
||||
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
|
||||
existing,
|
||||
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
|
||||
tenantIdArg: null!, tenantIdGiven: false,
|
||||
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
|
||||
|
||||
Assert.Equal(0, merged.TenantIdWaitSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
// same facts signal (safe default "false" off-device).
|
||||
var selfDeploying = payload["isSelfDeployingProfile"];
|
||||
Assert.True(selfDeploying == "true" || selfDeploying == "false");
|
||||
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
|
||||
// off-device; a build host is not a Cloud PC).
|
||||
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
|
||||
var cloudPc = payload["isCloudPc"];
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using AutopilotMonitor.Agent.V2;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the TenantIdWaitSeconds resolution order in
|
||||
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
|
||||
/// CLI arg > persisted bootstrap-config.json value > agent default (600 s).
|
||||
/// The regression this guards: a legacy bootstrap-config.json written before the
|
||||
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
|
||||
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
|
||||
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
|
||||
/// registry TenantId lands minutes after first boot).
|
||||
/// </summary>
|
||||
public sealed class TenantIdWaitResolutionTests
|
||||
{
|
||||
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
|
||||
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
|
||||
args: args,
|
||||
tenantId: "tenant-t",
|
||||
sessionId: "session-s",
|
||||
bootstrapConfig: bootstrapConfig,
|
||||
awaitConfig: null);
|
||||
|
||||
[Fact]
|
||||
public void No_cli_and_no_bootstrap_config_uses_default_600()
|
||||
{
|
||||
var cfg = Build(bootstrapConfig: null);
|
||||
|
||||
Assert.Equal(600, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
|
||||
{
|
||||
var legacy = new BootstrapConfigFile
|
||||
{
|
||||
BootstrapToken = "tok-legacy",
|
||||
TenantId = "tenant-legacy",
|
||||
TenantIdWaitSeconds = null,
|
||||
};
|
||||
|
||||
var cfg = Build(legacy);
|
||||
|
||||
Assert.Equal(600, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_zero_in_bootstrap_config_disables_wait()
|
||||
{
|
||||
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
|
||||
|
||||
var cfg = Build(optedOut);
|
||||
|
||||
Assert.Equal(0, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nonzero_bootstrap_config_value_wins_over_default()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured);
|
||||
|
||||
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_arg_wins_over_bootstrap_config_value()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured, "--tenant-id-wait", "300");
|
||||
|
||||
Assert.Equal(300, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cli_zero_wins_over_bootstrap_config_value()
|
||||
{
|
||||
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
|
||||
|
||||
var cfg = Build(configured, "--tenant-id-wait", "0");
|
||||
|
||||
Assert.Equal(0, cfg.TenantIdWaitSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_endpoint_unavailable_suppresses_distress_but_still_counts()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
int events = 0;
|
||||
tracker.ThresholdExceeded += (_, _) => events++;
|
||||
|
||||
// Platform-403s from a stopped endpoint: a distress report cannot reach it and the
|
||||
// DeviceNotRegistered classification would be wrong — nothing must be dispatched.
|
||||
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
|
||||
tracker.RecordFailure(403, "agent/register-session", endpointUnavailable: true);
|
||||
|
||||
Assert.Empty(fakeDistress.Calls);
|
||||
// The shutdown thresholds still apply — a dead endpoint must not retry forever.
|
||||
Assert.Equal(2, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(1, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_endpoint_unavailable_does_not_consume_streak_report_slot()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
// The streak opens with a platform-403 (no dispatch), then a live backend answers
|
||||
// with a genuine 403 — that one must still produce the streak's single report.
|
||||
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
|
||||
Assert.Single(fakeDistress.Calls);
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccess_rearms_distress_dispatch_for_the_next_streak()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
tracker.RecordFailure(401, "agent/config");
|
||||
tracker.RecordSuccess();
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
|
||||
Assert.Equal(2, fakeDistress.Calls.Count);
|
||||
Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[1].ErrorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
|
||||
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
|
||||
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
|
||||
/// Windows365 policy state; the MCMD agent family served other managed-desktop
|
||||
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
|
||||
/// </summary>
|
||||
public sealed class CloudPcDetectorTests
|
||||
{
|
||||
private static Func<string, bool> Probe(params string[] existingKeys)
|
||||
{
|
||||
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
|
||||
return path => set.Contains(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_markers_present_resolves_true()
|
||||
{
|
||||
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
|
||||
CloudPcDetector.Windows365MarkerKey,
|
||||
CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows365_key_alone_resolves_false()
|
||||
{
|
||||
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmde_service_alone_resolves_false()
|
||||
{
|
||||
// MCMD agent family also served other managed-desktop offerings.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_markers_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_probe_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throwing_probe_degrades_to_false()
|
||||
{
|
||||
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectIsCloudPc_never_throws_on_build_host()
|
||||
{
|
||||
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
|
||||
// A build host is not a Cloud PC, but the contract under test is "no throw",
|
||||
// not the detected value.
|
||||
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(Environment.MachineName, captured.DeviceName);
|
||||
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
|
||||
Assert.True(captured.IsUserDriven);
|
||||
// W365 marker rides the registration body. A build host is not a Cloud PC
|
||||
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
|
||||
// detector's SKIP-safe default is what must land here.
|
||||
Assert.False(captured.IsCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+28
-5
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
// Session df1fcf47: the DO collector observed the completion live, but the event
|
||||
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
|
||||
// the past — which dragged Sessions.StartedAt back and inflated the duration.
|
||||
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
|
||||
// last matched line.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do-poll", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
|
||||
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
// Not a replay — live observation must never trip the replay suppression,
|
||||
// even with a stale unrelated line on the tracker.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
int priorDoCalls = 0;
|
||||
int priorScriptCalls = 0;
|
||||
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
|
||||
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
|
||||
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.OnImeAgentVersion("1.0.0.0");
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
|
||||
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
|
||||
|
||||
Assert.Equal(1, priorVersionCalls);
|
||||
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
Action<string> priorVersion = _ => { };
|
||||
Action<AppPackageState> priorDo = _ => { };
|
||||
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
|
||||
Action<ScriptExecutionState> priorScript = _ => { };
|
||||
f.Tracker.OnImeAgentVersion = priorVersion;
|
||||
f.Tracker.OnDoTelemetryReceived = priorDo;
|
||||
|
||||
+25
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
|
||||
@@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests
|
||||
Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
|
||||
Assert.Null(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment()
|
||||
{
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Equal(
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected()
|
||||
{
|
||||
// On a Windows 365 Cloud PC a missing Autopilot profile is the normal state —
|
||||
// the event must not read as an alarm there.
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("Windows 365 Cloud PC", message);
|
||||
Assert.Contains("expected", message);
|
||||
Assert.DoesNotContain("no deployment profile was assigned", message);
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -153,6 +153,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("Device Setup", (string?)parsed["PhaseName"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emit_publishes_eventType_and_parsed_phase_to_TimelineEventStream()
|
||||
{
|
||||
// Post-reduce observer surface: gather-rule phase/event triggers subscribe here so
|
||||
// they fire in step with what the timeline shows (session 32312a32 regression).
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var published = new List<(string EventType, EnrollmentPhase Phase)>();
|
||||
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = "phase_transition",
|
||||
["phase"] = "FinalizingSetup",
|
||||
},
|
||||
State(),
|
||||
At);
|
||||
sut.Emit(
|
||||
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
|
||||
State(),
|
||||
At);
|
||||
|
||||
Assert.Equal(2, published.Count);
|
||||
Assert.Equal(("phase_transition", EnrollmentPhase.FinalizingSetup), published[0]);
|
||||
Assert.Equal(("enrollment_complete", EnrollmentPhase.Unknown), published[1]);
|
||||
// Publish happens AFTER the transport enqueue — wire ordering is authoritative.
|
||||
Assert.Equal(2, r.Transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimelineEventStream_subscriber_exception_does_not_break_the_emit_path()
|
||||
{
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var secondSubscriberCalls = 0;
|
||||
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
|
||||
State(),
|
||||
At);
|
||||
|
||||
// Emit neither threw (would surface as an EffectRunner retry = duplicated event)
|
||||
// nor skipped the remaining subscribers.
|
||||
Assert.Equal(1, r.Transport.EnqueueCount);
|
||||
Assert.Equal(1, secondSubscriberCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Phase_parameter_with_invalid_value_falls_back_to_Unknown()
|
||||
{
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
|
||||
/// device-authorization verdict, while an HTML body means the response came from the Azure
|
||||
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
|
||||
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
|
||||
/// the stopped legacy Function App and support chased a non-existent authorization problem.
|
||||
/// </summary>
|
||||
public sealed class BackendApiClientAuthClassificationTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_responder(request));
|
||||
}
|
||||
|
||||
private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType)
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, mediaType)
|
||||
});
|
||||
return new BackendApiClient(
|
||||
new HttpClient(handler),
|
||||
baseUrl: "https://backend.example",
|
||||
manufacturer: "Contoso",
|
||||
model: "TestBox",
|
||||
serialNumber: "SN123",
|
||||
useBootstrapTokenAuth: false,
|
||||
bootstrapToken: null,
|
||||
agentVersion: "2.0.0",
|
||||
logger: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_json_403_is_reported_as_device_not_authorized()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"{\"success\":false,\"message\":\"Device validation failed\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.False(ex.EndpointUnavailable);
|
||||
Assert.Contains("The device is not authorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"<!DOCTYPE html><html><head><title>Web App - Unavailable</title></head><body>stopped</body></html>",
|
||||
"text/html");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.True(ex.EndpointUnavailable);
|
||||
Assert.Contains("Web App - Unavailable", ex.Message);
|
||||
Assert.Contains("NOT a device-authorization failure", ex.Message);
|
||||
Assert.DoesNotContain("Check client certificate", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Html_body_with_generic_content_type_is_still_sniffed_as_platform_page()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
" <html><body>Error 403 - This web app is stopped.</body></html>",
|
||||
"text/plain");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.True(ex.EndpointUnavailable);
|
||||
Assert.Contains("HTML platform error page", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_401_keeps_authorization_message_and_status()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Unauthorized,
|
||||
"{\"success\":false,\"message\":\"certificate rejected\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(401, ex.StatusCode);
|
||||
Assert.False(ex.EndpointUnavailable);
|
||||
Assert.Contains("The device is not authorized", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool EnableTimezoneAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
|
||||
/// remote config): stamped into the esp_config_detected decision-signal payload so
|
||||
/// the DecisionEngine defangs a Device-phase ESP terminal failure into a bounded
|
||||
/// 60-min observation instead of failing immediately. Default: false.
|
||||
/// </summary>
|
||||
public bool EnableEspContinueAnywayObservation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom path to IME logs directory for testing.
|
||||
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
|
||||
@@ -272,9 +280,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// CloudDomainJoin keys and re-probes on every change until either resolution
|
||||
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
|
||||
/// <para>
|
||||
/// Set via the install-time CLI flag <c>--tenant-id-wait <sec></c>, persisted
|
||||
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
|
||||
/// default; the agent itself never assumes one.
|
||||
/// Set via the CLI flag <c>--tenant-id-wait <sec></c> or a persisted
|
||||
/// <c>bootstrap-config.json</c> value; when neither supplies a value the agent
|
||||
/// applies its own default of 600 s in BuildAgentConfiguration. The 0 default
|
||||
/// here is only the POCO baseline, not the effective runtime default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; } = 0;
|
||||
|
||||
@@ -16,8 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// <summary>
|
||||
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
|
||||
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
|
||||
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
|
||||
/// TenantId on first try. 0 = explicit opt-out (legacy fast-fail).
|
||||
/// Null / missing (files written before this property existed) = not configured —
|
||||
/// the agent-side default (600 s) applies. Nullable so a legacy file without the
|
||||
/// property cannot masquerade as an explicit 0 and silently disable the wait.
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; }
|
||||
public int? TenantIdWaitSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
|
||||
|
||||
// ---------------- Continue-Anyway observation — remote wins (operator-set tenant
|
||||
// opt-in; false must clear a stale cached true).
|
||||
agentConfig.EnableEspContinueAnywayObservation = remoteConfig.EnableEspContinueAnywayObservation;
|
||||
|
||||
// ---------------- UnrestrictedMode audit — track old/new for the caller so the
|
||||
// agent_unrestricted_mode_changed event can fire AFTER the
|
||||
// orchestrator emitter is alive (V1 parity with
|
||||
|
||||
@@ -155,7 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
// see the AuthCertificateRejected/DeviceNotRegistered distress signal
|
||||
// without 100 s of needless retry delay.
|
||||
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
|
||||
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config");
|
||||
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config", ex.EndpointUnavailable);
|
||||
lastException = ex;
|
||||
LastFetchAuthStatusCode = ex.StatusCode;
|
||||
break;
|
||||
@@ -328,6 +328,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
EnableGatherRuleDebugLog = false,
|
||||
EnableEspContinueAnywayObservation = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
|
||||
@@ -197,7 +197,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
/// <summary>
|
||||
/// Sanitizes log messages to prevent log injection attacks.
|
||||
/// Escapes control characters that could forge log entries.
|
||||
/// Redaction patterns can be added here later as needed.
|
||||
/// Transliterates typographic punctuation to ASCII: the log file is written as
|
||||
/// UTF-8 without BOM, and customer-side viewers (e.g. Outlook web attachment
|
||||
/// preview) guess cp1252 and render an em dash as mojibake ("—"). Only our own
|
||||
/// punctuation is mapped — genuine non-ASCII payload (device names, paths) is
|
||||
/// kept verbatim. Redaction patterns can be added here later as needed.
|
||||
/// </summary>
|
||||
private static string SanitizeLogMessage(string message)
|
||||
{
|
||||
@@ -206,6 +210,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
// Escape newlines and carriage returns to prevent log injection
|
||||
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
|
||||
|
||||
message = message
|
||||
.Replace('\u2014', '-') // — em dash
|
||||
.Replace('\u2013', '-') // – en dash
|
||||
.Replace("\u2192", "->") // → arrow
|
||||
.Replace("\u2026", "...") // … ellipsis
|
||||
.Replace('\u2018', '\'') // ' left single quote
|
||||
.Replace('\u2019', '\'') // ' right single quote
|
||||
.Replace('\u201C', '"') // " left double quote
|
||||
.Replace('\u201D', '"') // " right double quote
|
||||
.Replace('\u00A0', ' '); // non-breaking space
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
|
||||
@@ -88,6 +88,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static string BackendExpectedSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once a <see cref="CheckAndApplyUpdateAsync"/> run actually obtained a version
|
||||
/// from the manifest (regardless of whether an update was then needed/applied).
|
||||
/// <para>
|
||||
/// Consumed by the <c>--await-enrollment</c> path: the startup check runs pre-enrollment
|
||||
/// where OOBE frequently has no network yet, so a failed check there says nothing about
|
||||
/// the latest version. Once the MDM certificate appears the device provably has
|
||||
/// connectivity — the bootstrap retries the check exactly when this is still false.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setter is internal solely as a test seam (InternalsVisibleTo): the bootstrap-retry
|
||||
/// tests arm/reset the guard without running the real network check. Production code
|
||||
/// writes it only from <see cref="CheckAndApplyUpdateAsync"/>.
|
||||
/// </remarks>
|
||||
public static bool LastVersionCheckSucceeded { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: graceful-shutdown request hook, set by the runtime host once its
|
||||
/// shutdown machinery is armed (before the orchestrator starts) and cleared in its
|
||||
@@ -130,14 +147,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// to cover hotfixes that reuse the same version number.
|
||||
/// </param>
|
||||
/// <param name="triggerReason">
|
||||
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
|
||||
/// agent_version_check event (outcome=updated) can distinguish the two paths.
|
||||
/// "startup" (default), "await_enrollment" (post-cert retry in the bootstrap), or
|
||||
/// "runtime_hash_mismatch". Recorded in the marker file so the agent_version_check
|
||||
/// event (outcome=updated) can distinguish the paths.
|
||||
/// </param>
|
||||
/// <param name="downloadTimeoutMsOverride">
|
||||
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
|
||||
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
|
||||
/// and we know an update is required.
|
||||
/// </param>
|
||||
/// <param name="versionCheckTimeoutMsOverride">
|
||||
/// Overrides the version-v2.json fetch timeout (default 2500ms, tuned for boot speed).
|
||||
/// The await-enrollment retry passes a relaxed value: enrollment just completed, the
|
||||
/// device provably has network, and a stranded old build's last self-rescue chance is
|
||||
/// worth a few extra seconds before session registration.
|
||||
/// </param>
|
||||
/// <param name="allowDowngrade">
|
||||
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
|
||||
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
|
||||
@@ -151,7 +175,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
bool forceUpdate = false,
|
||||
string triggerReason = "startup",
|
||||
int? downloadTimeoutMsOverride = null,
|
||||
bool allowDowngrade = false)
|
||||
bool allowDowngrade = false,
|
||||
int? versionCheckTimeoutMsOverride = null)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
@@ -160,7 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
};
|
||||
|
||||
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
|
||||
// The await-enrollment retry replaces the startup verdict — its markers must win so
|
||||
// the agent_version_check event reflects the LATEST check, not the pre-network one.
|
||||
var writeMarkers = isStartupTrigger || string.Equals(triggerReason, "await_enrollment", StringComparison.Ordinal);
|
||||
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
|
||||
var versionCheckTimeoutMs = versionCheckTimeoutMsOverride ?? VersionCheckTimeoutMs;
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
|
||||
long zipSizeBytes = 0;
|
||||
@@ -174,16 +203,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch version-v2.json (2.5s timeout)
|
||||
// Step 1: Fetch version-v2.json (default 2.5s timeout, relaxed on retry triggers)
|
||||
var versionSw = Stopwatch.StartNew();
|
||||
string manifestSha256;
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log, versionCheckTimeoutMs);
|
||||
versionSw.Stop();
|
||||
versionCheckMs = versionSw.ElapsedMilliseconds;
|
||||
LastVersionCheckSucceeded = latestVersion != null;
|
||||
|
||||
if (latestVersion == null)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("version_check_failed", currentVersion, null, "version-v2.json fetch failed or timed out", log);
|
||||
return; // Could not determine latest version — continue with current
|
||||
}
|
||||
@@ -213,7 +243,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
|
||||
return;
|
||||
}
|
||||
@@ -230,7 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -249,7 +279,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
downloadMs = downloadTimerSw.ElapsedMilliseconds;
|
||||
if (!downloadOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
|
||||
return;
|
||||
}
|
||||
@@ -282,7 +312,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!integrityOk)
|
||||
{
|
||||
CleanupStaging(null, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +353,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!swapOk)
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -355,7 +385,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
{
|
||||
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
|
||||
}
|
||||
@@ -366,7 +396,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// Fetches version-v2.json from blob storage and returns the version string and optional SHA-256 hash.
|
||||
/// Returns (null, null) if the check fails or times out.
|
||||
/// </summary>
|
||||
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
|
||||
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -375,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
|
||||
{
|
||||
var json = await client.GetStringAsync(versionUrl);
|
||||
var obj = JObject.Parse(json);
|
||||
@@ -396,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
|
||||
log($"Self-update: version check timed out ({timeoutMs}ms) — skipping update");
|
||||
return (null, null);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
||||
|
||||
+39
-4
@@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
// is demoted to Info there instead of raising a misleading warning.
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var missingData = new Dictionary<string, object>
|
||||
{
|
||||
{ "profileAvailable", "0" },
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" },
|
||||
{ "isCloudPc", isCloudPc }
|
||||
};
|
||||
if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
|
||||
{
|
||||
@@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
missingData["ztdEndpointLatencyMs"] = probe.LatencyMs;
|
||||
missingData["ztdEndpointDetail"] = probe.Detail;
|
||||
|
||||
var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc);
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
presentation.Message,
|
||||
missingData,
|
||||
EventSeverity.Warning);
|
||||
presentation.Severity);
|
||||
|
||||
_logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}");
|
||||
var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}";
|
||||
if (isCloudPc)
|
||||
_logger.Info(logLine);
|
||||
else
|
||||
_logger.Warning(logLine);
|
||||
}
|
||||
|
||||
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
|
||||
@@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
&& profileAvailable?.ToString() == "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message and severity for the autopilot_profile_missing event. On Windows 365
|
||||
/// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the
|
||||
/// Windows 365 service, not by Autopilot), so the event is informational there;
|
||||
/// on physical devices it stays a warning about a missing profile ASSIGNMENT.
|
||||
/// </summary>
|
||||
internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc)
|
||||
{
|
||||
if (isCloudPc)
|
||||
{
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.",
|
||||
EventSeverity.Info);
|
||||
}
|
||||
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
EventSeverity.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
|
||||
/// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
|
||||
@@ -1021,6 +1052,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (snapshot.AllowContinueAnyway.HasValue)
|
||||
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
|
||||
// Tenant opt-in (remote config, not a registry fact) — only stamped when
|
||||
// enabled; absent = default hard-fail semantics in the engine.
|
||||
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
|
||||
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
|
||||
var derivationInputs = new Dictionary<string, string>(payload, StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
+8
-1
@@ -35,6 +35,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
private readonly IClock _clock;
|
||||
private readonly Persistence.StartupEventGate _startupGate;
|
||||
|
||||
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
|
||||
// esp_config_detected stamping time so a late remote-config merge takes effect on
|
||||
// the phase-triggered re-emissions. Null (tests / legacy callers) means disabled.
|
||||
private readonly Func<bool> _espContinueAnywayObservationEnabled;
|
||||
|
||||
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
|
||||
|
||||
// Restart dedup (StartupEventGate) — exemptions. boot_time is genuinely new per boot
|
||||
@@ -65,7 +70,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink signalIngress = null,
|
||||
IClock clock = null,
|
||||
Persistence.StartupEventGate startupGate = null)
|
||||
Persistence.StartupEventGate startupGate = null,
|
||||
Func<bool> espContinueAnywayObservationEnabled = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -74,6 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
_signalIngress = signalIngress;
|
||||
_clock = clock;
|
||||
_startupGate = startupGate;
|
||||
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+42
-1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
@@ -103,7 +104,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
|
||||
$"invalid regex pattern — rule can never match: {ex.Message}");
|
||||
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,6 +134,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-match trace lines are capped per file per run — the per-file summary line
|
||||
// still carries the full match count, this only bounds the per-match detail.
|
||||
private const int MaxTracedMatchesPerFile = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Writes one debug-trace line per regex match: the line number, the matched text,
|
||||
/// and every capture group that will land in the emitted event (same "0" exclusion
|
||||
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
|
||||
/// </summary>
|
||||
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
|
||||
int lineNumber, Match match, Regex pattern, int matchNumber)
|
||||
{
|
||||
if (matchNumber > MaxTracedMatchesPerFile)
|
||||
{
|
||||
if (matchNumber == MaxTracedMatchesPerFile + 1)
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
|
||||
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(fileName).Append(": line ").Append(lineNumber)
|
||||
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
|
||||
|
||||
var first = true;
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (!group.Success) continue;
|
||||
sb.Append(first ? " — groups: " : ", ");
|
||||
first = false;
|
||||
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
|
||||
}
|
||||
|
||||
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
|
||||
}
|
||||
|
||||
private static bool HasWildcard(string fileNamePart)
|
||||
=> !string.IsNullOrEmpty(fileNamePart) &&
|
||||
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
|
||||
@@ -258,6 +297,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -317,6 +357,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public sealed class GatherRuleDebugLog
|
||||
{
|
||||
public const string StageConfig = "config";
|
||||
public const string StagePhase = "phase";
|
||||
public const string StageTrigger = "trigger";
|
||||
public const string StageScope = "scope";
|
||||
public const string StageExec = "exec";
|
||||
|
||||
+11
-1
@@ -248,10 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
EnrollmentPhase previousPhase;
|
||||
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
@@ -291,6 +292,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
}
|
||||
|
||||
// Transition marker first, so every rule-level line below reads against it.
|
||||
// Written outside _scopeLock like all trace lines.
|
||||
if (previousPhase != newPhase)
|
||||
DebugLog(null, GatherRuleDebugLog.StagePhase,
|
||||
$"phase change: {previousPhase} -> {phaseName}" +
|
||||
(previousPhase == EnrollmentPhase.Unknown
|
||||
? " (first phase signal — scoped rules now evaluate against this phase)"
|
||||
: ""));
|
||||
|
||||
if (exitTraces != null)
|
||||
{
|
||||
foreach (var trace in exitTraces)
|
||||
|
||||
+60
-9
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -35,9 +36,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
// Derived from AllowedWmiQueryPrefixes: entries of the canonical form
|
||||
// "SELECT * FROM <class>" contribute their class name; any other entry
|
||||
// (none today) stays on the legacy literal-prefix path.
|
||||
private static readonly HashSet<string> AllowedWmiClasses;
|
||||
private static readonly List<string> ResidualWmiPrefixes;
|
||||
|
||||
// Canonical allowlist entry: exactly "SELECT * FROM <class>".
|
||||
private static readonly Regex CanonicalWmiEntryRegex = new Regex(
|
||||
@"^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded
|
||||
// by whitespace or end of string (a trailing WHERE clause is permitted,
|
||||
// "Win32_BIOSX" cannot pass as "Win32_BIOS").
|
||||
private static readonly Regex WmiQueryRegex = new Regex(
|
||||
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode. Internal (not private)
|
||||
// so GuardrailsParityTests can pin the guardrails.json display mirror to these
|
||||
// constants — enforcement stays code-only by design.
|
||||
internal static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
internal static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
{
|
||||
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
@@ -50,14 +71,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
};
|
||||
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
internal const int MaxCommandLength = 2000;
|
||||
|
||||
// Event log channels that are never readable, even in unrestricted mode.
|
||||
// Security carries the audit trail of user behaviour; the PowerShell channels
|
||||
// carry script-block logging, which routinely contains secrets in clear text.
|
||||
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
|
||||
// to display — enforcement lives here so a parse error cannot lift it.
|
||||
private static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
internal static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
{
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
@@ -67,7 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
// Mirrored in rules/guardrails.json ("blockedCommandPatterns") for portal/MCP
|
||||
// pre-flight display — enforcement lives here so a parse error cannot lift it.
|
||||
internal static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
{
|
||||
// Download / exfiltration
|
||||
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
|
||||
@@ -101,6 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
||||
DeriveWmiClassAllowlist(AllowedWmiQueryPrefixes, out AllowedWmiClasses, out ResidualWmiPrefixes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +139,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AllowedEventLogChannels = new List<string>();
|
||||
AllowedWmiClasses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
ResidualWmiPrefixes = new List<string>();
|
||||
}
|
||||
|
||||
private static void DeriveWmiClassAllowlist(
|
||||
IReadOnlyList<string> entries,
|
||||
out HashSet<string> classes,
|
||||
out List<string> residual)
|
||||
{
|
||||
classes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
residual = new List<string>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var match = CanonicalWmiEntryRegex.Match(entry.Trim());
|
||||
if (match.Success)
|
||||
classes.Add(match.Groups[1].Value);
|
||||
else
|
||||
residual.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedGuardrails()
|
||||
@@ -250,8 +294,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the WMI query (trimmed) matches an allowed prefix
|
||||
/// with boundary matching (next char must be whitespace or end of string).
|
||||
/// Returns true if the WMI query (trimmed) is "SELECT <* | property-list> FROM <class>"
|
||||
/// (optionally followed by a WHERE clause) against a class on the allowlist.
|
||||
/// A property projection exposes a strict subset of the allowed "SELECT *".
|
||||
/// </summary>
|
||||
public static bool IsWmiQueryAllowed(string query)
|
||||
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
|
||||
@@ -269,7 +314,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
return true;
|
||||
|
||||
var trimmed = query.Trim();
|
||||
return AllowedWmiQueryPrefixes.Any(prefix =>
|
||||
|
||||
var match = WmiQueryRegex.Match(trimmed);
|
||||
if (match.Success && AllowedWmiClasses.Contains(match.Groups[1].Value))
|
||||
return true;
|
||||
|
||||
// Non-canonical allowlist entries keep the legacy literal-prefix match.
|
||||
return ResidualWmiPrefixes.Any(prefix =>
|
||||
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||
}
|
||||
|
||||
+100
-10
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
continue;
|
||||
}
|
||||
|
||||
ThrowOnAuthFailure(response);
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
|
||||
using (response)
|
||||
{
|
||||
ThrowOnAuthFailure(response);
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -282,16 +282,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
|
||||
/// distinguish authentication failures from transient server errors.
|
||||
/// <para>
|
||||
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// retired Function App; Azure answers for a stopped app with a platform-level 403
|
||||
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
|
||||
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
|
||||
/// backend's auth layer at all (stopped/retired app, edge proxy, WAF). Reporting that
|
||||
/// as "device is not authorized" sends troubleshooting down the wrong path, so the
|
||||
/// two cases get distinct messages and the exception carries a flag.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void ThrowOnAuthFailure(HttpResponseMessage response)
|
||||
private static async Task ThrowOnAuthFailureAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized &&
|
||||
response.StatusCode != System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var statusText = $"{(int)response.StatusCode} {response.StatusCode}";
|
||||
|
||||
string body = null;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Body is diagnostic-only; classification below treats null as non-HTML.
|
||||
}
|
||||
|
||||
if (LooksLikeHtmlErrorPage(response, body))
|
||||
{
|
||||
var title = TryExtractHtmlTitle(body);
|
||||
var titleText = title != null ? $" (\"{title}\")" : string.Empty;
|
||||
var host = response.RequestMessage?.RequestUri?.Host ?? "(unknown host)";
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
$"Backend returned {statusText} with an HTML platform error page{titleText} from '{host}'. " +
|
||||
"This response did not come from the Autopilot Monitor backend — the endpoint appears stopped, " +
|
||||
"retired, or intercepted by a proxy. This is NOT a device-authorization failure; an outdated " +
|
||||
"agent build pointing at a stale ApiBaseUrl is the most likely cause.",
|
||||
(int)response.StatusCode,
|
||||
endpointUnavailable: true);
|
||||
}
|
||||
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {statusText}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a 401/403 response is an HTML page rather than a backend JSON/plain-text
|
||||
/// reply. Checks the declared Content-Type first, then falls back to sniffing the body
|
||||
/// prefix so a platform page served with a generic Content-Type is still recognized.
|
||||
/// </summary>
|
||||
private static bool LooksLikeHtmlErrorPage(HttpResponseMessage response, string body)
|
||||
{
|
||||
var mediaType = response.Content?.Headers?.ContentType?.MediaType;
|
||||
if (string.Equals(mediaType, "text/html", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return false;
|
||||
|
||||
var head = body.TrimStart();
|
||||
return head.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase) ||
|
||||
head.StartsWith("<html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort extraction of the HTML <title> (e.g. "Web App - Unavailable") so the
|
||||
/// log line names the platform page without dumping markup. Null when absent/oversized.
|
||||
/// </summary>
|
||||
private static string TryExtractHtmlTitle(string body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
try
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
body, @"<title[^>]*>\s*(.*?)\s*</title>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline,
|
||||
TimeSpan.FromMilliseconds(250));
|
||||
if (!match.Success) return null;
|
||||
|
||||
var title = match.Groups[1].Value;
|
||||
return title.Length == 0 ? null : (title.Length > 80 ? title.Substring(0, 80) : title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +411,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
|
||||
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized —
|
||||
/// or, when <see cref="EndpointUnavailable"/> is set, that the 401/403 was a platform-level
|
||||
/// HTML response (stopped/retired app, edge proxy) rather than a backend auth verdict.
|
||||
/// </summary>
|
||||
public class BackendAuthException : Exception
|
||||
{
|
||||
@@ -340,9 +423,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// </summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0) : base(message)
|
||||
/// <summary>
|
||||
/// True when the 401/403 carried an HTML platform error page — the endpoint itself is
|
||||
/// stopped/retired/proxied and the status says nothing about this device's authorization.
|
||||
/// </summary>
|
||||
public bool EndpointUnavailable { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
EndpointUnavailable = endpointUnavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private DateTime? _lastReportTime;
|
||||
private int _reportCount;
|
||||
|
||||
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
|
||||
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
|
||||
private readonly bool _isCloudPc;
|
||||
|
||||
public DistressReporter(
|
||||
string baseUrl,
|
||||
string tenantId,
|
||||
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
string serialNumber,
|
||||
string agentVersion,
|
||||
AgentLogger logger,
|
||||
X509Certificate2 clientCertificate = null)
|
||||
X509Certificate2 clientCertificate = null,
|
||||
bool isCloudPc = false)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_manufacturer = manufacturer;
|
||||
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
_serialNumber = serialNumber;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
_isCloudPc = isCloudPc;
|
||||
|
||||
// Capture cert context. Each field is independently nullable so the backend can tell
|
||||
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
|
||||
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
|
||||
@@ -97,7 +97,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
|
||||
@@ -126,7 +127,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
startupGate: _startupEventGate));
|
||||
startupGate: _startupEventGate,
|
||||
// Live accessor (not the current value): a remote-config merge landing after
|
||||
// factory construction must still reach the phase-triggered re-emissions.
|
||||
espContinueAnywayObservationEnabled: () => _agentConfig.EnableEspContinueAnywayObservation));
|
||||
|
||||
// Session caa6cf50 gate-starvation fix: the EspAndHelloTracker's user-ESP-apps-settled
|
||||
// probe reads the IME log host, which is constructed further down. Same closure-over-
|
||||
@@ -471,8 +475,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (officeHost != null) hosts.Add(officeHost);
|
||||
|
||||
// M4.6.δ — Gather-rules runtime executor. Runs "startup" rules once the agent is up
|
||||
// and "interval" rules on their timers; "phase_change" / "on_event" rules are driven
|
||||
// by the host's SignalIngress.SignalPosted subscription (MON-A1).
|
||||
// and "interval" rules on their timers; "phase_change" / "phase_exit" / "on_event"
|
||||
// rules are driven by the post-reduce TimelineEventStream (emitted timeline events),
|
||||
// NOT the raw SignalPosted stream — the raw phase can run minutes ahead of the
|
||||
// engine-declared one (RealmJoin gate, session 32312a32). See TimelineEventStream doc.
|
||||
if (_remoteConfig.GatherRules != null && _remoteConfig.GatherRules.Count > 0)
|
||||
{
|
||||
hosts.Add(new GatherRuleExecutorHost(
|
||||
@@ -484,7 +490,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
rules: _remoteConfig.GatherRules,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
|
||||
timelineEvents: timelineEvents));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
|
||||
@@ -91,6 +91,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// (tests / legacy callers) means trace events always flow.
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
|
||||
// esp_config_detected stamping time so a remote-config merge that lands after
|
||||
// construction still takes effect. Null (tests / legacy callers) means disabled.
|
||||
private readonly Func<bool>? _espContinueAnywayObservationEnabled;
|
||||
|
||||
// Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
|
||||
private System.Threading.Timer? _maxLifetimeTimer;
|
||||
private int _terminatedFired;
|
||||
@@ -107,6 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private TelemetrySignalEmitter? _signalEmitter;
|
||||
private TelemetryTransitionEmitter? _transitionEmitter;
|
||||
private EventTimelineEmitter? _timelineEmitter;
|
||||
private TimelineEventStream? _timelineEvents;
|
||||
private BackPressureEventObserver? _backPressureObserver;
|
||||
private IDeadlineScheduler? _scheduler;
|
||||
private ClassifierRegistry? _classifierRegistry;
|
||||
@@ -172,7 +178,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -213,6 +220,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
_schedulerOverride = schedulerOverride;
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -413,7 +421,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_transport, _eventSequenceCounter, _sessionId, _tenantId, _traceEventsEnabled);
|
||||
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
|
||||
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
|
||||
// Post-reduce timeline observer: EventTimelineEmitter publishes every emitted event's
|
||||
// (eventType, phase) here. Fed to the collector-host factory at step 14 so gather-rule
|
||||
// phase_change/on_event triggers key off the ENGINE-REDUCED timeline (RealmJoin gate
|
||||
// et al. respected) instead of raw pre-reduce signals — see TimelineEventStream doc.
|
||||
_timelineEvents = new TimelineEventStream();
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter, _timelineEvents);
|
||||
_backPressureObserver = new BackPressureEventObserver(eventEmitter, _clock);
|
||||
|
||||
// 5) Deadlines + Classifiers.
|
||||
@@ -653,7 +666,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
var surfaces = _componentFactory.CreateCollectorHosts(
|
||||
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool);
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
|
||||
timelineEvents: _timelineEvents);
|
||||
CollectorSurfaces = surfaces;
|
||||
_collectorHosts = surfaces.Hosts;
|
||||
foreach (var host in _collectorHosts)
|
||||
@@ -766,6 +780,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (snapshot.AllowContinueAnyway.HasValue)
|
||||
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
|
||||
// Tenant opt-in (remote config, not a registry fact) — only stamped when
|
||||
// enabled; absent = default hard-fail semantics in the engine.
|
||||
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
|
||||
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
|
||||
_ingress!.Post(
|
||||
kind: DecisionSignalKind.EspConfigDetected,
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
getPackageStates: () => imeHost.PackageStates,
|
||||
onDoTelemetryReceived: pkg =>
|
||||
{
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
// null source timestamp: poll-driven completion, no log line backs it —
|
||||
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
@@ -82,7 +82,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
Persistence.StartupEventGate? startupGate = null)
|
||||
Persistence.StartupEventGate? startupGate = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -92,7 +93,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// when it reads the FirstSync SkipUser/SkipDevice registry values, so that Fix 8's
|
||||
// reducer guards have the SkipUserEsp/SkipDeviceEsp state facts to read.
|
||||
_collector = new Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector(
|
||||
sessionId, tenantId, post, logger, ingress, clock, startupGate);
|
||||
sessionId, tenantId, post, logger, ingress, clock, startupGate,
|
||||
espContinueAnywayObservationEnabled);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
+44
-40
@@ -4,7 +4,6 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
@@ -19,17 +18,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly bool _unrestrictedMode;
|
||||
private int _disposed;
|
||||
|
||||
// MON-A1: drive phase_change / on_event gather triggers from the central signal stream.
|
||||
// V1 fired these from MonitoringService on every EnrollmentEvent (OnPhaseChanged(evt.Phase)
|
||||
// on phase change + OnEvent(evt.EventType) per event); the V2 host previously only called
|
||||
// UpdateRules, so 5 of 10 shipped rules (incl. the only enabled one, dsregcmd at
|
||||
// FinalizingSetup) silently never fired. In V2 every event is an InformationalEvent signal
|
||||
// carrying eventType + (optional) phase in its payload. Null on test fakes / non-SignalIngress
|
||||
// sinks — then signal-triggers degrade off (startup + interval rules still run).
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private Action<DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
|
||||
// MON-A1 (revised): drive phase_change / phase_exit / on_event gather triggers from the
|
||||
// POST-REDUCE emitted timeline (TimelineEventStream), not from the raw signal stream.
|
||||
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
|
||||
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
|
||||
// with the RAW collector payload — before the reducer has applied gates like the
|
||||
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
|
||||
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
|
||||
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
|
||||
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
|
||||
// built to read. The raw feed also never carried engine-emitted event types at all, so
|
||||
// on_event rules on enrollment_complete (documented) could never fire. The emitted-event
|
||||
// feed restores V1 semantics and matches what the UI timeline shows ("collect once when
|
||||
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
|
||||
// then phase/event triggers degrade off (startup + interval rules still run).
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
private Action<string, EnrollmentPhase>? _timelineHandler;
|
||||
private readonly object _sync = new object();
|
||||
private string? _lastPhaseName;
|
||||
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
|
||||
|
||||
public GatherRuleExecutorHost(
|
||||
string sessionId,
|
||||
@@ -40,7 +46,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
List<GatherRule> rules,
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null)
|
||||
string? gatherDebugLogPath = null,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -60,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -71,46 +78,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor.UnrestrictedMode = _unrestrictedMode;
|
||||
_executor.UpdateRules(_rules);
|
||||
|
||||
// MON-A1: observe the central signal stream so phase_change / on_event rules fire.
|
||||
if (_observableIngress != null && _signalPostedHandler == null)
|
||||
// MON-A1 (revised): observe the emitted timeline so phase/event triggers fire in
|
||||
// step with what the timeline actually shows.
|
||||
if (_timelineEvents != null && _timelineHandler == null)
|
||||
{
|
||||
_signalPostedHandler = OnSignalPosted;
|
||||
_observableIngress.SignalPosted += _signalPostedHandler;
|
||||
_timelineHandler = OnTimelineEventEmitted;
|
||||
_timelineEvents.EventEmitted += _timelineHandler;
|
||||
}
|
||||
|
||||
_logger.Info(
|
||||
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, signalTriggers={(_observableIngress != null)}.");
|
||||
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, timelineTriggers={(_timelineEvents != null)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates posted signals into the executor's phase_change / on_event triggers (MON-A1).
|
||||
/// Phase: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when
|
||||
/// the observed phase changes and parses to an <see cref="EnrollmentPhase"/> (raw collector
|
||||
/// phase strings that aren't enum names are ignored — they carry no gather-rule meaning).
|
||||
/// Event: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every
|
||||
/// InformationalEvent's eventType. The executor dispatches rule execution on the ThreadPool,
|
||||
/// so this stays off the signal-posting hot path; it also dedups phase rules per (rule, phase).
|
||||
/// Translates emitted timeline events into the executor's triggers. Phase: fire
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when a
|
||||
/// phase-declaration event (Phase != Unknown) moves the timeline to a new phase — this
|
||||
/// is by construction the engine-reduced phase, so deferred transitions (RealmJoin gate)
|
||||
/// fire the rules exactly when the timeline shows them. Event: fire
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every emitted
|
||||
/// event's type — including engine-emitted types (enrollment_complete, phase_transition,
|
||||
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
|
||||
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
|
||||
/// effect path; it also dedups phase rules per (rule, phase).
|
||||
/// </summary>
|
||||
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
|
||||
{
|
||||
if (payload == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
|
||||
&& !string.IsNullOrEmpty(phaseName)
|
||||
&& !string.Equals(phaseName, _lastPhaseName, StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<EnrollmentPhase>(phaseName, ignoreCase: true, out var phase))
|
||||
if (phase != EnrollmentPhase.Unknown && phase != _lastPhase)
|
||||
{
|
||||
_lastPhaseName = phaseName;
|
||||
_lastPhase = phase;
|
||||
_executor.OnPhaseChanged(phase);
|
||||
}
|
||||
|
||||
if (kind == DecisionSignalKind.InformationalEvent
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& !string.IsNullOrEmpty(eventType))
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
{
|
||||
_executor.OnEvent(eventType);
|
||||
}
|
||||
@@ -118,17 +122,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Verbose($"GatherRuleExecutorHost: signal-trigger dispatch failed: {ex.Message}");
|
||||
_logger.Verbose($"GatherRuleExecutorHost: timeline-trigger dispatch failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_observableIngress != null && _signalPostedHandler != null)
|
||||
if (_timelineEvents != null && _timelineHandler != null)
|
||||
{
|
||||
try { _observableIngress.SignalPosted -= _signalPostedHandler; }
|
||||
try { _timelineEvents.EventEmitted -= _timelineHandler; }
|
||||
catch { /* best-effort unsubscribe during shutdown */ }
|
||||
_signalPostedHandler = null;
|
||||
_timelineHandler = null;
|
||||
}
|
||||
// GatherRuleExecutor is IDisposable; no explicit Stop beyond unsubscribe. Rely on Dispose.
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <c>spool.pendingItemCount</c> / <c>spool.fileSizeBytes</c>. Nullable — fakes and
|
||||
/// spool-less configurations pass null and the metrics fields are simply absent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="timelineEvents"/> flows into the <c>GatherRuleExecutorHost</c> so
|
||||
/// <c>phase_change</c> / <c>phase_exit</c> / <c>on_event</c> gather triggers key off the
|
||||
/// ENGINE-REDUCED emitted timeline (post-reduce, RealmJoin gate respected) instead of raw
|
||||
/// pre-reduce signals — see <see cref="TimelineEventStream"/>. Nullable — fakes pass null
|
||||
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
@@ -47,6 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool);
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Post-reduce observer surface for emitted timeline events: <see cref="Telemetry.Events.EventTimelineEmitter"/>
|
||||
/// publishes every <see cref="EnrollmentEvent"/> it emits as an <c>(eventType, phase)</c> pair,
|
||||
/// AFTER the decision engine has reduced the originating signal and its effects have been
|
||||
/// executed.
|
||||
/// <para>
|
||||
/// This is deliberately different from <see cref="SignalIngress.SignalPosted"/>, which fires
|
||||
/// at enqueue time on the producer thread and therefore only ever carries the RAW collector
|
||||
/// payload — before the reducer has applied gates like the RealmJoin completion gate that can
|
||||
/// defer or suppress a phase transition. Consumers that must agree with what the timeline
|
||||
/// actually shows (the gather-rule phase/event triggers) subscribe here, not to the raw
|
||||
/// signal stream. See session 32312a32: a <c>phase_change</c> gather rule on FinalizingSetup
|
||||
/// fired at the raw <c>EspPhaseChanged(FinalizingSetup)</c> signal (ESP exit), 7 minutes
|
||||
/// before the engine — deferred behind the RealmJoin gate — actually declared
|
||||
/// <c>phase_transition(FinalizingSetup)</c> on the timeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Threading</b>: <see cref="Publish"/> runs on the ingress worker thread (the effect
|
||||
/// execution path). Subscribers must stay fast and must not block; subscriber exceptions are
|
||||
/// swallowed per handler so the emit path can never throw (an exception here would surface
|
||||
/// as an EffectRunner transient retry and duplicate the emitted event).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TimelineEventStream
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private int _consecutiveFailures;
|
||||
private DateTime? _firstFailureUtc;
|
||||
private int _thresholdFired; // 0/1 — Interlocked exchange makes the event single-shot
|
||||
private int _distressDispatched; // 0/1 per failure streak — reset by RecordSuccess
|
||||
|
||||
public AuthFailureTracker(
|
||||
int maxFailures,
|
||||
@@ -77,18 +78,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public void RecordSuccess()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _consecutiveFailures, 0) == 0) return; // no-op if already zero
|
||||
Interlocked.Exchange(ref _distressDispatched, 0);
|
||||
lock (_windowLock) { _firstFailureUtc = null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — on the FIRST
|
||||
/// failure this fires a single distress report via the constructor-injected
|
||||
/// <see cref="DistressReporter"/> (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
|
||||
/// Subsequent failures are logged + counted but do not dispatch further distress signals.
|
||||
/// Emits <see cref="ThresholdExceeded"/> the first time either ceiling is crossed;
|
||||
/// subsequent calls after termination is armed are no-ops.
|
||||
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — the first
|
||||
/// qualifying failure of a streak fires a single distress report via the
|
||||
/// constructor-injected <see cref="DistressReporter"/> (401 → AuthCertificateRejected,
|
||||
/// 403 → DeviceNotRegistered). Subsequent failures are logged + counted but do not
|
||||
/// dispatch further distress signals. Emits <see cref="ThresholdExceeded"/> the first
|
||||
/// time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
|
||||
/// </summary>
|
||||
public void RecordFailure(int statusCode, string operation)
|
||||
/// <param name="endpointUnavailable">
|
||||
/// True when the 401/403 was a platform-level HTML response (stopped/retired app, edge
|
||||
/// proxy — see <see cref="BackendAuthException.EndpointUnavailable"/>). Suppresses the
|
||||
/// distress dispatch: the report would go to the very endpoint that just answered with a
|
||||
/// platform error page, and its DeviceNotRegistered classification would be wrong anyway.
|
||||
/// Counting and the shutdown thresholds are unaffected. Should a later failure in the
|
||||
/// same streak come from a live backend, that one still dispatches the streak's report.
|
||||
/// </param>
|
||||
public void RecordFailure(int statusCode, string operation, bool endpointUnavailable = false)
|
||||
{
|
||||
if (Volatile.Read(ref _thresholdFired) == 1) return;
|
||||
|
||||
@@ -102,10 +112,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
firstFailureAt = _firstFailureUtc.Value;
|
||||
}
|
||||
|
||||
// V1 parity — only the first consecutive failure produces a distress report. Repeat
|
||||
// hits at 2..N use the local log only; the backend already knows from the first
|
||||
// report that the device is unauthorized.
|
||||
if (count == 1 && _distressReporter != null)
|
||||
// V1 parity — only one distress report per failure streak. Repeat hits use the local
|
||||
// log only; the backend already knows from the first report that the device is
|
||||
// unauthorized. Endpoint-unavailable failures never dispatch (nothing to send to) but
|
||||
// also never consume the streak's report slot.
|
||||
if (endpointUnavailable)
|
||||
{
|
||||
if (_distressReporter != null && Volatile.Read(ref _distressDispatched) == 0)
|
||||
{
|
||||
_logger.Info(
|
||||
$"AuthFailureTracker: distress report suppressed for {operation} (http {statusCode}) — " +
|
||||
"endpoint unavailable (platform error page), a report cannot reach a stopped endpoint.");
|
||||
}
|
||||
}
|
||||
else if (_distressReporter != null &&
|
||||
Interlocked.CompareExchange(ref _distressDispatched, 1, 0) == 0)
|
||||
{
|
||||
var distressType = statusCode == 403
|
||||
? DistressErrorType.DeviceNotRegistered
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
|
||||
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
|
||||
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
|
||||
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
|
||||
/// to any Hyper-V VM):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
|
||||
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
|
||||
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
|
||||
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
|
||||
/// so the agent needs no System.ServiceProcess dependency.</item>
|
||||
/// </list>
|
||||
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
/// physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
/// served other managed-desktop offerings).
|
||||
/// <para>
|
||||
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
|
||||
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
|
||||
/// validation trusts exclusively the cert-CN-bound Graph lookup
|
||||
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
|
||||
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
|
||||
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CloudPcDetector
|
||||
{
|
||||
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
|
||||
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
|
||||
|
||||
public static bool DetectIsCloudPc()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ResolveIsCloudPc(SubKeyExists);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
|
||||
/// marker-AND semantics can be exercised without a real registry (same seam contract
|
||||
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
|
||||
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
|
||||
/// whether it exists; a throwing probe degrades to <c>false</c>.
|
||||
/// </summary>
|
||||
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
|
||||
{
|
||||
if (subKeyExists == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SubKeyExists(string subKeyPath)
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
|
||||
{
|
||||
return key != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
// Feed the central auth-failure tracker so the first-failure distress dispatches
|
||||
// and the shutdown threshold advances. V1 parity: no retry on auth-failure —
|
||||
// backend has definitively rejected the cert/token.
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
|
||||
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
|
||||
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
|
||||
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
|
||||
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
|
||||
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
|
||||
// IsSelfDeployingProfile is the additive classification signal.
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
|
||||
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitImeAgentVersion(version);
|
||||
}
|
||||
|
||||
private void OnDoTelemetryReceived(AppPackageState app)
|
||||
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnDoTelemetryReceived?.Invoke(app);
|
||||
EmitDoTelemetry(app);
|
||||
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitAppState(app, oldState, newState);
|
||||
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
|
||||
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app)
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id)) return;
|
||||
|
||||
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
// Two trigger paths with different time grounding (session df1fcf47: a single
|
||||
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
|
||||
// session StartedAt back 7h and inflated the reported duration):
|
||||
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
|
||||
// event time — resolve/clamp it and apply historic-replay suppression.
|
||||
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
|
||||
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
|
||||
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
|
||||
DateTime now;
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
if (sourceTimestampUtc.HasValue)
|
||||
{
|
||||
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
now = _clock.UtcNow;
|
||||
derivedFromClock = true;
|
||||
rawSourceTs = null;
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
|
||||
@@ -81,10 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
internal const string ReasonParamKey = "reason";
|
||||
|
||||
private readonly TelemetryEventEmitter _emitter;
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
|
||||
public EventTimelineEmitter(TelemetryEventEmitter emitter)
|
||||
public EventTimelineEmitter(TelemetryEventEmitter emitter, TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
public void Emit(
|
||||
@@ -142,6 +144,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
};
|
||||
|
||||
_emitter.Emit(evt);
|
||||
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
$"{(bootstrapTokenGiven ? "*" : "")}, " +
|
||||
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
|
||||
$"{(tenantIdGiven ? "*" : "")}, " +
|
||||
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
|
||||
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
|
||||
$"{(tenantIdWaitGiven ? "*" : "")})." +
|
||||
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
|
||||
}
|
||||
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
|
||||
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
|
||||
// Not given → carry the prior value through, INCLUDING null. Persisting a
|
||||
// number here would freeze the agent-side default into the file; persisting
|
||||
// null keeps "not configured" distinguishable from an explicit opt-out (0).
|
||||
TenantIdWaitSeconds = tenantIdWaitGiven
|
||||
? tenantIdWaitSeconds
|
||||
: (existing?.TenantIdWaitSeconds ?? 600),
|
||||
: existing?.TenantIdWaitSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
|
||||
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
|
||||
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
|
||||
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
|
||||
// property) is "not configured", NOT an opt-out — the default must still apply.
|
||||
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
|
||||
const int tenantIdWaitDefaultSeconds = 600;
|
||||
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
|
||||
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
|
||||
tenantIdWaitSeconds = parsedTenantIdWait;
|
||||
else if (bootstrapConfig != null)
|
||||
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
|
||||
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
|
||||
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
|
||||
|
||||
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
@@ -121,6 +122,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
// Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
|
||||
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
|
||||
|
||||
RetrySelfUpdateAfterEnrollment(logger, consoleMode);
|
||||
}
|
||||
|
||||
// Event-driven TenantId wait — kicks in only when the user opted-in via
|
||||
@@ -152,6 +155,81 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
isWhiteGloveResume: isWhiteGloveResume,
|
||||
cleanupServiceFactory: cleanupServiceFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature of the self-update invocation performed by
|
||||
/// <see cref="RetrySelfUpdateAfterEnrollment"/> — mirrors the
|
||||
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> parameters this path uses.
|
||||
/// </summary>
|
||||
internal delegate Task SelfUpdateInvoker(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
string triggerReason,
|
||||
int downloadTimeoutMs,
|
||||
bool allowDowngrade,
|
||||
int versionCheckTimeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam replacing the real <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> call
|
||||
/// in <see cref="RetrySelfUpdateAfterEnrollment"/> (same static-hook idiom as
|
||||
/// <see cref="SelfUpdater.RequestGracefulShutdown"/>). Only the in-repo test project
|
||||
/// (InternalsVisibleTo) can set it; production code never assigns this.
|
||||
/// </summary>
|
||||
internal static SelfUpdateInvoker UpdateInvokerOverride;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
|
||||
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
|
||||
/// network yet — its failure says nothing about the latest version. The certificate is
|
||||
/// proof of connectivity, and this is the last chance to swap a stranded old build
|
||||
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
|
||||
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
|
||||
/// On update success the process restarts and this method never returns; the
|
||||
/// await-enrollment config was already removed, so the new binary proceeds normally.
|
||||
/// </summary>
|
||||
internal static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
|
||||
{
|
||||
if (SelfUpdater.LastVersionCheckSucceeded)
|
||||
return; // Startup check reached the manifest — its verdict stands.
|
||||
|
||||
try
|
||||
{
|
||||
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
|
||||
var agentDir = System.IO.Path.GetDirectoryName(
|
||||
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
|
||||
SelfUpdateInvoker invoke = UpdateInvokerOverride;
|
||||
if (invoke == null)
|
||||
{
|
||||
invoke = (version, dir, console, reason, downloadTimeoutMs, allowDowngrade, versionCheckTimeoutMs) =>
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: version,
|
||||
agentDir: dir,
|
||||
consoleMode: console,
|
||||
triggerReason: reason,
|
||||
downloadTimeoutMsOverride: downloadTimeoutMs,
|
||||
allowDowngrade: allowDowngrade,
|
||||
versionCheckTimeoutMsOverride: versionCheckTimeoutMs);
|
||||
}
|
||||
invoke(
|
||||
Program.GetAgentVersion(),
|
||||
agentDir,
|
||||
consoleMode,
|
||||
"await_enrollment",
|
||||
60000,
|
||||
// Fresh --await-enrollment installs have no cached remote config yet, so
|
||||
// there is no admin downgrade override to honour on this path.
|
||||
false,
|
||||
10000).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same contract as the startup call site: the updater swallows its own errors;
|
||||
// anything escaping must not break bootstrap — the current binary continues.
|
||||
logger.Warning($"Await-enrollment: self-update retry failed unexpectedly: {ex.Message}. Continuing with current version.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -135,7 +135,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
uploadBatchSize: uploadBatchSize,
|
||||
// Live read: remote config is merged into agentConfig mid-session, so the
|
||||
// trace-event gate must follow it rather than snapshot at construction.
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents))
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents,
|
||||
espContinueAnywayObservationEnabled: () => agentConfig.EnableEspContinueAnywayObservation))
|
||||
{
|
||||
using (var shutdown = new ManualResetEventSlim(false))
|
||||
using (var shutdownComplete = new ManualResetEventSlim(false))
|
||||
|
||||
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
serialNumber: hardware.SerialNumber,
|
||||
agentVersion: agentVersion,
|
||||
logger: logger,
|
||||
clientCertificate: clientCertificate);
|
||||
clientCertificate: clientCertificate,
|
||||
isCloudPc: CloudPcDetector.DetectIsCloudPc());
|
||||
|
||||
var emergencyReporter = new EmergencyReporter(
|
||||
apiClient: backendApiClient,
|
||||
|
||||
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
};
|
||||
|
||||
var evidence = new Evidence(
|
||||
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
|
||||
/// values set-once (mirror of the registry self-deploying fact), the profile reason
|
||||
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
|
||||
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
|
||||
/// arrival requirement, so no behavioural gate is added — the observation is the
|
||||
/// engine-side context that a session starting at Account Setup is the expected shape.
|
||||
/// </summary>
|
||||
public sealed class CloudPcObservationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
|
||||
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
|
||||
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
|
||||
})).NewState;
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-1", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "true");
|
||||
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.True(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
Assert.Equal(
|
||||
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
|
||||
state.ScenarioProfile.Reason);
|
||||
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
|
||||
Assert.Equal(SessionStage.SessionStarted, state.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-2", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "false");
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
|
||||
// Set-once: a later contradicting re-read must not overwrite the first sighting
|
||||
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
|
||||
state = ReduceFacts(engine, state, 2, "true");
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_foldsPositiveMarker_only()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-3", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
|
||||
var census = DecisionStateSignalCensus.Build(cloudPcState);
|
||||
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
|
||||
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
|
||||
|
||||
var physicalState = ReduceFacts(engine, initial, 1, "false");
|
||||
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
|
||||
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
|
||||
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
|
||||
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
|
||||
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
|
||||
/// observation advisory instead. Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Defang + 60-min AdvisoryCompletion arming (vs the classic 30-min window).</item>
|
||||
/// <item>Opt-out / ContinueAnyway-off / self-deploying-scenario regressions still hard-fail.</item>
|
||||
/// <item>Real-user desktop + Hello-disabled completes eagerly through Finalizing; the
|
||||
/// terminal enrollment_complete carries the espSoftFailure marker.</item>
|
||||
/// <item>Window expiry without desktop un-defangs to esp_terminal_failure.</item>
|
||||
/// <item>Deadline fire with desktop but unknown Hello promotes to AwaitingHello.</item>
|
||||
/// <item>Category recovery ("Try again") resolves the advisory (classic 4910a5a5 hook).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ContinueAnywayObservationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 15, 16, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null,
|
||||
string sourceOrigin = "test")
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: sourceOrigin,
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the session-53d1e9f6 shape up to (but excluding) the ESP terminal failure:
|
||||
/// Continue-Anyway profile with the tenant observation opt-in stamped into the
|
||||
/// EspConfigDetected payload, Hello policy disabled, DeviceSetup entered —
|
||||
/// AccountSetup never reached (the 30-min Device-ESP wall kills it first).
|
||||
/// </summary>
|
||||
private static DecisionState SetupObservationEligibleSession(
|
||||
DecisionEngine engine,
|
||||
bool observationOptIn = true,
|
||||
bool continueAnyway = true,
|
||||
bool helloPolicyDisabled = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
var espConfig = new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspSyncFailureTimeoutMinutes] = "30",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = continueAnyway ? "true" : "false",
|
||||
};
|
||||
if (observationOptIn)
|
||||
{
|
||||
espConfig[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
}
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), espConfig)).NewState;
|
||||
if (helloPolicyDisabled)
|
||||
{
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
}
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionStep ApplyDeviceSetupTerminalFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
|
||||
engine.Reduce(state, MakeSignal(
|
||||
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(38),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["failureType"] = "Provisioning_DeviceSetup_Certificates_Failed",
|
||||
["failedSubcategory"] = "Certificates",
|
||||
["category"] = "DeviceSetup",
|
||||
}));
|
||||
|
||||
// ============================================================ defang path ====
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_DefangsToObservationAdvisory()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
Assert.Null(state.AccountSetupEnteredUtc);
|
||||
|
||||
var preStage = state.Stage;
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
// No terminal — stage unchanged, advisory anchored, no LastFailureTrigger.
|
||||
Assert.Equal(preStage, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
Assert.Equal("DeviceSetup", step.NewState.EspAdvisoryFailureCategory!.Value);
|
||||
Assert.Null(step.NewState.LastFailureTrigger);
|
||||
|
||||
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.Equal("esp_failure_defanged_continueanyway_observation", advisory.Parameters!["advisoryReason"]);
|
||||
Assert.Equal("Warning", advisory.Parameters!["severity"]);
|
||||
Assert.Equal("true", advisory.Parameters!["mayHaveContinuedAnyway"]);
|
||||
|
||||
Assert.DoesNotContain(step.Effects, e =>
|
||||
e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_Arms60MinObservationWindow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(deadline);
|
||||
// 60-min observation window (user decision 2026-08-08) — NOT the classic 30-min
|
||||
// advisory-completion window.
|
||||
Assert.Equal(T0.AddMinutes(38).AddMinutes(60), deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
// ==================================================== negative / regression ====
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithoutOptIn_StillHardFails()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, observationOptIn: false);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
SingleTimelineEffect(step, "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_ButContinueAnywayDisabled_StillHardFails()
|
||||
{
|
||||
// The observation only makes sense when the ESP profile actually offers the
|
||||
// "Continue anyway" button — without it the user cannot reach the desktop.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, continueAnyway: false);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
SingleTimelineEffect(step, "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_OnSelfDeployingProfile_StillHardFails()
|
||||
{
|
||||
// Scope gate: self-deploying/kiosk flows have no interactive user who could press
|
||||
// "Continue anyway" — observation would only ever expire. Immediate fail is truthful.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EnrollmentFactsObserved, T0.AddMinutes(3),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.IsSelfDeployingProfile] = "true" })).NewState;
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicAdvisory_WithAccountSetupEntered_KeepsClassicReasonAndWindow()
|
||||
{
|
||||
// Regression: the opt-in must not change the classic post-AccountSetup advisory —
|
||||
// same advisoryReason literal, same 30-min window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
Assert.NotNull(state.AccountSetupEnteredUtc);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.Equal("esp_failure_defanged_continueanyway_with_accountsetup", advisory.Parameters!["advisoryReason"]);
|
||||
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.Equal(T0.AddMinutes(38).AddMinutes(30), deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
// ========================================== real end: desktop completion ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationAdvisory_ThenRealUserDesktop_CompletesThroughFinalizing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
|
||||
// User pressed "Continue anyway" → signed in → DAD-validated real-user desktop.
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42)));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Contains(":ContinueAnywayObservation", step.Transition.Trigger);
|
||||
// Hello disabled → synthetic skip recorded by the fast-path.
|
||||
Assert.NotNull(step.NewState.HelloResolvedUtc);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationCompletion_EnrollmentComplete_CarriesEspSoftFailureMarker()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(42.1), DeadlineNames.FinalizingGrace));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
|
||||
|
||||
var complete = SingleTimelineEffect(step, "enrollment_complete");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
|
||||
Assert.Equal("true", payload["espSoftFailure"]);
|
||||
Assert.Equal("continue_anyway_observation", payload["completionSource"]);
|
||||
Assert.Equal("DeviceSetup", payload["espSoftFailureCategory"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanCompletion_EnrollmentComplete_OmitsEspSoftFailureMarker()
|
||||
{
|
||||
// A session without any advisory must not carry the marker — regression guard for
|
||||
// the BuildEnrollmentCompleteEffect change.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
30, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(20))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(21))).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(50, T0.AddMinutes(21.1), DeadlineNames.FinalizingGrace));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
var complete = SingleTimelineEffect(step, "enrollment_complete");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
|
||||
Assert.False(payload.ContainsKey("espSoftFailure"));
|
||||
Assert.False(payload.ContainsKey("completionSource"));
|
||||
}
|
||||
|
||||
// =============================================== window expiry / deadline ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_ExpiresWithoutDesktop_UnDefangsToEspTerminalFailure()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
Assert.Null(state.DesktopArrivedUtc);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(60, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
// The failure cause IS the original ESP failure — same reason literal, likely-stuck
|
||||
// promotion stays armed via LastFailureTrigger=EspTerminalFailure.
|
||||
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger!.Value);
|
||||
|
||||
var failed = SingleTimelineEffect(step, "enrollment_failed");
|
||||
Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
|
||||
Assert.Equal(
|
||||
"continue_anyway_observation_window_expired_without_completion_evidence",
|
||||
failed.Parameters!["advisoryReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_FiresWithDesktopAndDisabledHello_CompletesInsteadOfFailing()
|
||||
{
|
||||
// Ordering variant: desktop arrived but (hypothetically) no eager completion ran —
|
||||
// the deadline-fire conjunction must accept desktop + hello for the observation
|
||||
// variant without the (structurally impossible) IME user-session gate.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
// Hello policy lands AFTER the failure; desktop arrives; no eager completion since
|
||||
// the fast-path needs the policy fact at desktop-arrival time.
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
65, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(43),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_FiresWithDesktopButUnknownHello_PromotesToAwaitingHello()
|
||||
{
|
||||
// Hello policy never observed (no PassportForWork value) — the hello prerequisite is
|
||||
// structurally unresolvable. The never-observed promote must apply to the observation
|
||||
// variant instead of failing a desktop-proven session.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.Null(state.HelloPolicyEnabled);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.HelloSafety));
|
||||
}
|
||||
|
||||
// ======================================================= category recovery ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationAdvisory_DeviceSetupRecovery_ResolvesAdvisory()
|
||||
{
|
||||
// "Try again" path: the ESP re-runs the device phase and DeviceSetup resolves to
|
||||
// success — the 4910a5a5 recovery hook must match the observation advisory too.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(55)));
|
||||
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureResolvedUtc);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "esp_failure_advisory_resolved");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
|
||||
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
|
||||
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
|
||||
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
|
||||
.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
|
||||
Assert.False(o.RegistrySelfDeployingProfile!.Value);
|
||||
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
|
||||
Assert.True(o.CloudPc!.Value);
|
||||
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
|
||||
|
||||
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
|
||||
Assert.Null(o.WhiteGloveSealingPatternSeen);
|
||||
|
||||
+93
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
|
||||
// ================================================================== helpers
|
||||
|
||||
// ------------------------------------------------ isCloudPc annotation (W365)
|
||||
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
|
||||
// profile records the "no Device-ESP phase expected" expectation as the reason.
|
||||
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
|
||||
// and EspConfig keeps the CONFIGURED FirstSync semantics.
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
|
||||
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
|
||||
{
|
||||
Dictionary<string, string> Facts() => new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
};
|
||||
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
|
||||
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
|
||||
|
||||
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, first);
|
||||
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
|
||||
|
||||
// The repeat must be a full no-op: the Cloud PC annotation already owns the
|
||||
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
|
||||
// from re-claiming it (which would bump EvidenceOrdinal every post).
|
||||
Assert.Same(p1, p2);
|
||||
Assert.Equal(0, p2.EvidenceOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = "false",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
|
||||
{
|
||||
// Defensive combination (not expected in the field — Cloud PCs are never
|
||||
// Autopilot-registered): the deterministic self-deploying Mode seed must
|
||||
// survive; only the reason slot goes to the more specific annotation.
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user