Compare commits

...

281 Commits

Author SHA1 Message Date
Oliver Kieselbach c770bbffcf build: keep NuGet audit advisories as warnings in the warning freeze
A CVE published against an already-referenced package must not block an
unrelated hotfix deploy; advisories are handled by bumping the package.
WarningsNotAsErrors NU1901-NU1904 - everything else stays fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:49:27 +02:00
Oliver Kieselbach 3fbdc8ef62 build: freeze .NET builds at zero warnings (warnings-as-errors)
Repo-wide Directory.Build.props: TreatWarningsAsErrors +
MSBuildTreatWarningsAsErrors. Applies to compiler, MSBuild and NuGet
restore warnings including audit advisories (NU1901-NU1904) - a newly
published CVE fails the build until addressed (fail-closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:44:40 +02:00
Oliver Kieselbach 35cea4fe64 build: clear all remaining .NET build warnings to zero
- CmTraceLogParser: annotate TryParseLine (string? line, [NotNullWhen(true)]
  out entry), init string properties (CS8618/CS8625/CS8604)
- Shared: internal NotNullWhen polyfill for netstandard2.0; multi-target
  netstandard2.0;net10.0 so net10 consumers use the BCL attribute and the
  polyfill cannot collide via InternalsVisibleTo (CS0433)
- AgentEndpointMigrationRules.TryNormalizeTarget: now carries [NotNullWhen(true)]
- Functions.csproj: drop DataProtection/Cryptography.Xml security pins made
  redundant by .NET 10 framework-package pruning (NU1510)
- Functions.Tests.csproj: add Microsoft.AspNetCore.App FrameworkReference so
  pruning applies there too and the pins stay unnecessary (NU1903/NU1904)
- Agent tests: nullable test-helper param, Assert.NotNull before dereference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:38:41 +02:00
Oliver Kieselbach ade6c4948f annotations: tenant switcher on the overview page + GA-lane guard on the global list
/annotations now carries the standard cross-tenant scope (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 never receive the platform-internal
globaladmin lane (endpoint-test-pinned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:27:31 +02:00
Oliver Kieselbach 83da522dd7 ci: freeze the web lint warning cap at zero (batch 5)
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:23:44 +02:00
Oliver Kieselbach c15394df87 web: refactor context/sidebar/theme effect-state sync, last lint sites to zero (batch 3)
- AuthContext: delete the SSR isLoading effect — effects never run on the
  server, the guard was unreachable dead code.
- TenantContext: mirror user.tenantId via adjust-during-render; the value
  is still retained while auth resolves and cleared on settled sign-out.
- Global/Tenant notification contexts: scope-masked empty list derived in
  the provider value instead of effect-clearing; loading flag moved into
  the canonical async wrapper.
- New shared hooks/useMediaQuery (useSyncExternalStore, configurable
  server snapshot) — consumed by GlobalSidebar (isDesktop) and
  McpTerminalDemo (prefers-reduced-motion, finished transcript derived).
- GlobalSidebar: route-mode active section derived from pathname;
  active-group and pathname auto-expansion as adjust-during-render, state
  now holds user toggles only.
- ThemeContext: theme read via useSyncExternalStore backed by
  lib/themeStore (manual preference beats OS preference in the snapshot;
  same-tab writes notify through the store's listener list).
- TipOfTheDay: random tip via useSyncExternalStore with module-memoized
  pick (build-time Math.random would freeze one tip and mismatch on
  hydration); re-rolls per mount via cleanup.
- Navbar: previewMode block removed entirely — nothing in the codebase
  consumes the flag anymore, it only persisted itself.
- activation page: ?demo flag via useSyncExternalStore, tri-state null
  sentinel dropped.

Lint is at zero react-hooks warnings repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:55 +02:00
Oliver Kieselbach 810a6f1f2f web: derive instead of effect-sync across reset/clamp/seed lint sites (batch 2)
Structural fixes for the set-state-in-effect sites that were genuine
state synchronization, using the React-documented patterns:

- adjust-during-render (compare-prev) for reset-on-dep-change: dashboard
  page reset + tenant-filter clear, InstallsTab/MappedSoftwareTab page
  resets, TenantManagementSection URL seeding + page reset,
  OpsAlertRulesSection draft re-seeding, SessionReportsSection deep-link
  auto-open, ReportSessionModal result reset.
- clamp/derive at read: dashboard effectivePage (also removes the
  documented handleNextPage race), backups detail effectiveTableName,
  useBlockDevice scope-masked set, InstallProgress and
  useProgressDerivedData live timers (startedAt-tagged tick state),
  sessions timeBreakdown read guard.
- key-based reset: SessionReportsSection note editor, DeleteConfirmModal
  content remount.
- inverted state: sessions page stores collapsedPhases so new phases are
  expanded by default with no sync effect; hash deep-link expandAll moved
  into its timer callback.
- tagged result state: useScriptDisplayNames stores {key, map} and serves
  the empty map on key mismatch — same staleness guarantee, no
  effect-phase clearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:36 +02:00
Oliver Kieselbach 019ac3959e web: clear the three preserve-manual-memoization warnings (batch 4)
- useProgressDerivedData: hoist the currentDownload computation into a
  module-level pure helper — the in-loop Map mutation and early return
  kept the compiler from memoizing the body inline.
- DeviceDetailsCard: derive bootTime from events inside the uptime memo
  (deps [events]) — the outer const is a live alias into event data that
  the compiler's alias analysis treats as maybe-mutated.
- useSessionEvents: route the queued follow-up re-invocation through a
  ref — a memoized callback that calls itself directly cannot be
  memoized. The now-traceable mount effect gets the standard wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:56:07 +02:00
Oliver Kieselbach 9236aba46e web: canonical inner-async wrappers for all fetch-effect lint sites (batch 1)
Convert every useEffect that directly invokes a state-setting fetch
callback to the canonical inner-async shape (const run = async () => {...};
void run();) — the react-hooks/set-state-in-effect rule traces direct
calls but accepts effect-scoped async functions. Semantic no-op: an async
body runs synchronously until its first await, so execution order is
byte-identical. Leading loading/error/data resets that are part of the
fetch flow move to the top of run in unchanged order.

52 files, ~60 sites. Lint 99 -> 36 warnings; remaining sites are
reset/derive effects and sensitive context/sidebar refactors (next
batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:55:49 +02:00
Oliver Kieselbach fda52666d6 web: clear set-state-in-effect lint warning on the annotations page
The annotations overview pushed the ESLint warning count to 100, one over
the frozen --max-warnings 99 cap, failing CI since 66d25f57's run. The
react-hooks/set-state-in-effect rule flags any direct effect call into a
state-setting useCallback, even with every setState behind an await.

Restructure instead of suppressing: loading/error begin-state moves from
fetchPage into the interaction sites (filter selects, load-more; mount is
covered by the initial state), and the effect invokes fetchPage through an
inner async wrapper so no setState is synchronously reachable. Lint is
back to exactly 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:19:52 +02:00
Oliver Kieselbach dcec6a1863 web: friendly empty/error states on the annotations overview page
A 404 (backend without the route yet, deploy skew) now renders the
regular empty state instead of an error; genuine load failures show a
calm non-technical retry hint in amber instead of raw statusText in red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:42:29 +02:00
Oliver Kieselbach 041626494e ci: bump actions/cache v4 -> v6 (Node 24 runtime)
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:41:10 +02:00
Oliver Kieselbach fd32a90d57 web: label the GA annotation lane as internal, with a visibility badge
"Autopilot Monitor Team" read like a vendor-to-customer message channel.
The lane only ever renders for platform-scope users, so it now says
"Internal (Autopilot Monitor)" and carries a "never visible to the
tenant" badge on the session card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:39:25 +02:00
Oliver Kieselbach 90b8243894 annotations: move tenant list route under the /api/sessions cert-exclusion prefix
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:35:54 +02:00
Oliver Kieselbach 66d25f57db Cloud-PC-aware autopilot_profile_missing: Info instead of Warning on W365
On Windows 365 Cloud PCs a missing Autopilot profile (ProfileAvailable=0)
is the expected state - Cloud PCs are provisioned by the Windows 365
service, not by Autopilot - so the warning shown for physical devices is
misleading there.

- Agent: emit autopilot_profile_missing as Info with a Cloud-PC-specific
  message when CloudPcDetector matches; payload now carries isCloudPc.
  Evidence collection (ZTD verdict, registry, endpoint probe) unchanged.
- Web: DeviceDetailsCard renders a neutral info note instead of the amber
  warning for Cloud PC sessions (session.isCloudPc, sticky, covers
  existing sessions; or the new event marker).
- MCP: resource-catalog documents the new isCloudPc data field.
- Docs: Cloud PC exception noted in agent/autopilot-ztd-diagnostics.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:34 +02:00
Oliver Kieselbach 84a0424789 annotations: tenant-scoped overview page listing judged sessions
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:04 +02:00
Oliver Kieselbach 596788cc26 fix dark mode 2026-08-12 14:32:24 +02:00
Oliver Kieselbach aec2d249e1 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-12 14:23:13 +02:00
Oliver Kieselbach a63f126b88 update dark theme script executions 2026-08-12 14:23:06 +02:00
dependabot[bot] d94a91780c chore(deps): bump the web group across 1 directory with 5 updates (#129)
Bumps the web group with 5 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/signalr](https://github.com/dotnet/aspnetcore) | `10.0.0` | `10.0.11` |
| [@types/leaflet](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/leaflet) | `1.9.21` | `1.9.22` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.12` | `16.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.12` | `16.3.0` |



Updates `@microsoft/signalr` from 10.0.0 to 10.0.11
- [Release notes](https://github.com/dotnet/aspnetcore/releases)
- [Changelog](https://github.com/dotnet/aspnetcore/blob/main/docs/ReleasePlanning.md)
- [Commits](https://github.com/dotnet/aspnetcore/commits)

Updates `@types/leaflet` from 1.9.21 to 1.9.22
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/leaflet)

Updates `next` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.0)

Updates `@next/bundle-analyzer` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.0/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: "@microsoft/signalr"
  dependency-version: 10.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/leaflet"
  dependency-version: 1.9.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 16:18:11 +02:00
Oliver Kieselbach 0b83fe2390 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-11 16:09:00 +02:00
Oliver Kieselbach 75c0c60717 web: use absolute destination for sign-in redirect in global error boundary
Next 16.3's new @next/next/no-location-assign-relative-destination rule
flags relative URLs assigned to window.location, pushing the lint warning
count past the CI cap of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:09:13 +02:00
Oliver Kieselbach 29e7aa675a web: native title tooltips explaining each annotation verdict
Same wording as the customer docs; plain browser tooltip, no cursor
change. Applied to the verdict buttons and the read-only pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:06:50 +02:00
dependabot[bot] c28d64fe27 chore(deps-dev): bump tsx (#128)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.1 to 4.23.11
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.11)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 14:45:08 +02:00
Oliver Kieselbach b2723168fd reports: snapshot session annotations into the report ZIP
SubmitReportAsync adds annotations.json (all lanes) to the package and
annotationCount to report-metadata.json. All lanes are safe: report
blobs are readable only through the GA-only session-reports routes.
Fail-soft like the tenant-context snapshot; the copy outlives the
session=s retention/delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:38:33 +02:00
Oliver Kieselbach d05d8ef302 web: annotations card collapsed by default with verdict summary header
The verdict comes at the end of a diagnosis, so the card must not take
space from the details/analysis work above it. Clickable chevron header
per section convention; count + verdict pills stay visible collapsed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:33:40 +02:00
Oliver Kieselbach 2c48968ec6 session annotations: role-separated human verdicts on sessions (flywheel labels)
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + 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; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:30:44 +02:00
Oliver Kieselbach 2f8ad82b0a gather-rules: mirror hard blocks in portal/MCP, stamp author on PUT-upsert; agent: bootstrap retry tests
Codex follow-ups to 1ebc2675:

- guardrails.json now mirrors blockedCommandPatterns + maxCommandLength and
  carries C:\Windows\System32\config in blockedFilePrefixes; portal
  (guardValidation.ts) and MCP (rule-validation.ts) enforce them in agent
  order (before the unrestricted-mode early-return), so hard-blocked commands
  no longer show "Allowed" in unrestricted tenants. GuardrailsParityTests
  pins the JSON mirror to the C# constants.
- gather-rule schema: collectorType enum gains json/xml (agent + portal
  supported both; MCP validate_rule falsely rejected them); MCP validates
  json/xml targets with file semantics and rejects activePhases +
  activeFromPhase set together (backend 400 parity); authoring guide updated.
- Author anti-spoof: tenant + global UpdateRule stamp the caller's JWT
  display name before the service call, closing the PUT-upsert hole where a
  non-existing ruleId stored the payload author; true updates still preserve
  the original. GatherRulesFunctionAuthorEndpointTests drives the real
  endpoints (first HttpRequestData-level test) proving body authors never win.
- AgentBootstrap.RetrySelfUpdateAfterEnrollment: internal + UpdateInvokerOverride
  test seam (RequestGracefulShutdown idiom), LastVersionCheckSucceeded setter
  internal; AgentBootstrapSelfUpdateRetryTests pin the guard, the relaxed
  await_enrollment timeouts, and the swallow-everything exception contract
  with save/restore of the static state.

Functions 3958, Agent-Core 2200, Web 670, MCP 543 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:29:05 +02:00
Oliver Kieselbach 4b74995e79 shared: drop Private Preview wording from ContactEmail seeding comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:34:16 +02:00
Oliver Kieselbach 5d8d9e8d40 web: show tenant contact email in config report tenant status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 17:58:47 +02:00
Oliver Kieselbach 25bd2c175e web: bump nanoid to 3.3.18 and js-yaml to 4.3.1 (Dependabot high alerts)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:00:59 +02:00
Oliver Kieselbach 830edb98e7 agent: suppress distress dispatch when the auth failure came from a dead endpoint
Follow-up to 13dd6723: a platform-403 (stopped/retired app, HTML error
page) now skips the DeviceNotRegistered/AuthCertificateRejected distress
report - it would be sent to the very endpoint that just answered with a
platform page, and its classification would be wrong. Counting and the
shutdown thresholds are unchanged, so a dead endpoint still drives the
agent to its soft shutdown instead of retrying forever.

The one-report-per-streak gate moves from "count == 1" to a streak-scoped
flag: an endpoint-unavailable failure no longer consumes the streak's
report slot, so a later genuine 403 from a live backend still dispatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:42:43 +02:00
Oliver Kieselbach 1ebc2675b1 gather-rules: WQL property projections, portal enable-gating, author from JWT
- WMI guard accepts SELECT <property-list> FROM <class> for any class already
  allowed via SELECT * (strict data subset); same algorithm in agent, portal
  validator, and MCP validate_rule (incl. trim fix); guardrails.json unchanged
- Portal refuses to enable custom rules with not-allowed targets: create lands
  disabled, invalid edit force-disables, "Blocked on devices" badge, GitHub-issue
  hint for allowlist requests; agent stays the enforcement boundary
- Rule Author is stamped from the creator's token (display name > UPN) at create
  and preserved through every update, incl. cross-tenant GA edits
- bumpVersion keeps the segment count ("1.0.0" -> "1.1.0")

Customer feedback: SELECT BatteryStatus FROM Win32_Battery + on_change was
impossible because SELECT * re-emits on every charge-percent fluctuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:41:14 +02:00
Oliver Kieselbach 283c2ed214 web,ci: make Grant-AutopilotMonitorAddOn.ps1 actually downloadable
The docs page and the admin UI both told customers to run
Grant-AutopilotMonitorAddOn.ps1, but the script was never published
anywhere -- it only exists in this private repo. Publish it on the
download alias next to the agent artifacts and reference it:

- build-agent.yml: upload the script on publish_as_stable runs
- admin UI: copied grant command now starts with an irm -OutFile
  download line; prose links the script directly
- utils/config.ts: new AGENT_DOWNLOAD_URL registry constant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:33:59 +02:00
Oliver Kieselbach 13dd6723da agent: classify platform-403 as endpoint-unavailable + self-update retry after await-enrollment
Field case sits-d.cloud 2026-08-09: a pinned pre-cutover agent (2.0.1282)
hit the stopped legacy Function App. Azure answers with a platform-level
403 HTML page, which the agent reported as "device is not authorized" -
support chases a non-existent authorization problem, and the stranded
build never rescues itself because the startup self-update check runs
pre-enrollment where OOBE has no network.

Two fixes:
- BackendApiClient reads the 401/403 body: an HTML response cannot come
  from our backend, so it is classified as endpoint stopped/retired/
  proxied (BackendAuthException.EndpointUnavailable, page title in the
  message) instead of a device-authorization verdict.
- AgentBootstrap retries the self-update check once after the MDM
  certificate appears (proof of connectivity) when the startup check
  never reached the manifest, with relaxed timeouts (10s check / 60s
  download). An applied update restarts into a current binary BEFORE
  session registration hits a retired endpoint. Marker files are also
  written on this trigger so agent_version_check reflects the latest
  verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:32:10 +02:00
Oliver Kieselbach cb6d80a76e agent: transliterate typographic punctuation to ASCII in log output
Agent logs are written UTF-8 without BOM; cp1252-guessing viewers
(Outlook web attachment preview) render em dashes as mojibake. Map
em/en dash, arrow, ellipsis, curly quotes and NBSP to ASCII at the
central sanitize step so every message is covered without touching
call sites. Genuine non-ASCII payload (paths, names) stays verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:41:44 +02:00
Oliver Kieselbach 024c90d68f ci: cache the MiniLM embedding model so MCP tests stop 429ing on huggingface
The vector-search tests tokenize with the real Xenova/all-MiniLM-L6-v2
model. transformers.js caches it under node_modules by default, which npm ci
recreates on every run — so each CI run re-downloaded the tokenizer/weights
from huggingface.co, and a rate-limited response (429) failed the whole MCP
job (run 31252869254; unrelated to the commit under test). Persist the model
via actions/cache and point HF_CACHE_DIR (already honored by
vector-search-provider.ts) at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:37:13 +02:00
Oliver Kieselbach 54044c5d9c esp: continue-anyway observation mode — bounded 60-min defang for device-phase failures
Classic UD fleets held at the Device-ESP timeout wall by one slow blocking
app showed ~100% Failed (tenant c9787ba2: 948/1000 esp_terminal_failure)
although the profile allows "Continue anyway" and users demonstrably reach
the desktop — the classic advisory defang requires AccountSetupEnteredUtc
and never applied there.

New operator-set tenant opt-in EnableEspContinueAnywayObservation
(ConfigVersion 36 -> 37, default off):

- DecisionCore: a Device-phase EspTerminalFailure with ContinueAnyway
  allowed + opt-in observed (EspConfigDetected payload fact — kernel stays
  config-free) + Classic/Unknown non-pre-prov scenario defangs into the
  advisory rail with a 60-min AdvisoryCompletion window. Real end via the
  DAD-validated real-user desktop + Hello gate (IME user-session gate is
  structurally unavailable without the AccountSetup anchor): eager
  completion on DesktopArrived, category recovery via the 4910a5a5 hook,
  expiry un-defangs to the original esp_terminal_failure — every observed
  session still gets a terminal verdict.
- Honest outcome: enrollment_complete under an unresolved advisory carries
  espSoftFailure/completionSource (also on the classic post-AccountSetup
  advisory path); backend stamps EspSoftFailure/CompletionSource on the
  Sessions row (+ index mirror, terminal-tuple preservation); web renders
  an amber "with issues" badge plus a session-detail banner. No new
  terminal status — Succeeded + marker.
- Agent: remote-config field merged into AgentConfiguration; stamped into
  both esp_config_detected emitters (orchestrator bootstrap via live
  Func<bool> seam, DeviceInfoCollector via host/factory pass-through).
- Admin UI: GA-only toggle in the tenant management modal (next to
  Bootstrap Token) + stored/runtime rows in the tenant config report;
  GA-gated on both write paths (PUT silently reverts for tenant admins,
  field patch rejects with 400).

Tests: 13 new ContinueAnywayObservationTests; DecisionCore 513, Agent V2
2162, Functions 3948, SummaryDialog 44, vitest 632, tsc clean.
Docs: docs/agent/continue-anyway-observation.md (OKF) + index/log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:28:45 +02:00
Oliver Kieselbach 3b3e8a7614 analyze: delay auto-analyze visibility 30s so rules stop racing the terminal flush
The enqueue fires in the very ingest batch that carries enrollment_failed /
enrollment_complete — by design the FIRST event of the agent's terminal
sequence. The worker's hot loop dequeued sub-second and evaluated rules
against a stream still missing the terminal-flush rows (stuck-promotion's
synthetic app_install_failed, app_tracking_summary, agent_shutting_down),
and the RuleResult dedup made the gap permanent. Session 53d1e9f6:
ANALYZE-ESP-004's optional app_install_failed condition never matched and
{{appName}} rendered as a literal placeholder (confidence 80, factor never
fired). A 30 s visibilityTimeout covers the flush including the agent's
late-event grace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:28:25 +02:00
Oliver Kieselbach 115b4ec8c2 bootstrap: promote Windows 365 Cloud PC relax to v2.3 production script
Install-AutopilotMonitor.ps1 takes over the field-tested v2.3-dev.3 logic
unchanged (code-only diff vs dev: ScriptVersion + log file name); comments
condensed to customer-facing descriptions, dev changelog collapsed into one
v2.3 entry. Pester suite moved to Install-AutopilotMonitor.Tests.ps1 (neutral
test user names), dev script and dev suite removed. Test-ShouldBootstrapAgent
dry-run tester mirrors the Cloud PC trigger and the guard-4 uptime exemption.

Pester 29/29 green under PS 5.1 and pwsh 7; parse + pure-ASCII checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 09:18:13 +02:00
Oliver Kieselbach 8ec5a5debd offboarding: close the un-offboard race — config freeze + fail-closed wipe guard
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.

Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
  tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
  (a revert restores a pre-offboarding snapshot with Disabled=false — the
  exact race)
- TenantOffboardingHandler re-reads the config before the destructive
  post-drain phases and fail-closes with 'unoffboard_race' when the
  tombstone is gone but the wipe has not run (last line of defense)

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:25:03 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 312e849573 email: welcome + farewell templates to green branding
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
Oliver Kieselbach 7433df528d web: mark-led OG/Twitter image + PNG apple-touch-icon
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:17:23 +02:00
Claude d86ae18a5e distress: carry the W365 Cloud PC marker into Devices Not Registered
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:

- Agent: DistressReporter captures isCloudPc at construction (optional ctor
  arg, test fakes stay source-compatible); BackendClientFactory feeds it from
  CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
  1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
  (legacy rows -> false), structured log + AgentDistressReport AI event key;
  GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
  semantics as the Sessions IsCloudPc merge) and the data-quality notice
  names the new self-reported field. GA global/distress-reports serializes
  the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
  the session table) + conditional hint pointing at the Windows 365 Cloud PC
  Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
  the optional flag. Docs: third transport rail in
  backend/cloudpc-device-validation.md + log entry.

Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
2026-08-07 10:52:35 +00:00
Oliver Kieselbach 1c5f69a9fe w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs

Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.

- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
  (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
  GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
  30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
  filter; exact-match guard; Graph 403 = definitive "grant missing", not
  transient), SecurityValidator CN extraction + fallback stage + hard gate,
  wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
  GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
  comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
  Cloud-PC relax is active — Cloud PCs run headless for days before first
  connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
  privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log

Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv

* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"

Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.

Two transport rails:

- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
  (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
  -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
  filter end to end (SearchSessions + QueryRawSessions + pagination
  fingerprint + all three in-memory predicates; MCP search_sessions /
  query_raw_sessions params + summary projection; web type, Cloud PC pill,
  SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
  recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
  and annotates the scenario profile reason
  cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
  expectation that Device-ESP already ran headless at provisioning and the
  session starts at Account Setup. Deliberately reason+observation only:
  no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
  classified (Classic), EspConfig keeps configured-FirstSync semantics.
  Positive marker surfaces as cloud_pc_marker in the audit-trail census.
  Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
  reason branch from re-claiming the slot on repeat posts (would bump
  EvidenceOrdinal every post). Additive-nullable observation - no state
  schema bump (v4 convention), serializer roundtrip pinned.

Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).

Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 12:31:08 +02:00
Oliver Kieselbach 17affb6dfd Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-06 18:20:07 +02:00
Oliver Kieselbach acf4d0eeaa bootstrap(dev): v2.3-dev.2 - Cloud PC detection on captured evidence
First W365 field test (2026-08-06) proved the assumed WMI signature wrong:
a real Cloud PC reports the generic Manufacturer='Microsoft Corporation' +
Model='Virtual Machine'; the 'Cloud PC Enterprise ...' string exists only in
Intune/Graph inventory. Probe on the live Cloud PC captured two local
markers instead: HKLM\SOFTWARE\Microsoft\Windows365 and the
CloudManagedDesktopExtension service (W365 management agent).

Get-CloudPcModel is replaced by Test-IsCloudPc requiring BOTH markers (each
alone has look-alikes: W365-Boot physical clients / legacy managed-desktop
agents). Raw identity and both marker states are always logged. Tests
updated to the marker model (26 tests, green under PS 5.1 and pwsh 7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:14:58 +02:00
Oliver Kieselbach 4c1889d011 bootstrap(dev): always log raw Manufacturer/Model for Cloud PC diagnosis
First W365 field test failed: the log said only "CloudPC: False" with no way
to tell whether Win32_ComputerSystem reported unexpected values or the query
failed. Get-CloudPcModel now logs the raw identity on every run and the
error message on failure. Detection pattern itself unchanged until the
probe from the real Cloud PC delivers evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:59:49 +02:00
Oliver Kieselbach da4de79a9d web: keep Load all clear of Prev/Next on phone-width pagination (#126) 2026-08-06 17:10:15 +02:00
Oliver Kieselbach 6f3f35e1cd web: one-click reset to "All tenants (aggregated)" in tenant scope selector
While a specific tenant is selected on an aggregated-capable page, an x
button next to the dropdown jumps straight back to the aggregate view
instead of requiring a dropdown roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:55:20 +02:00
Oliver Kieselbach ca22be5cd0 notifications: dedupe duplicate Pre-Provisioning Completed webhook + traffic-light icons
The V2 agent emits whiteglove_complete twice (raw Shell-Core event 62407 +
engine timeline event, ~1s apart). The Pending status write was not
idempotent (terminal guard allowed Pending->Pending as a "transition") and
the ingest fallback additionally forced the transitioned flag, so tenants
received the Teams card twice (HSD report). Pending->Pending is now refused
in IsTerminalTransitionAllowed, and the ingest fallback distinguishes an
already-sealed session (duplicate, no notification) from a genuine write
failure.

Notification titles now use neutral traffic-light circles (green/yellow/red)
instead of rocket/checkmark/cross/siren emojis (customer feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:13:19 +02:00
Oliver Kieselbach c421b81751 web: fix all 16 exhaustive-deps lint warnings, ratchet cap 115 -> 99
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.

Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.

The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:10:04 +02:00
Oliver Kieselbach 7636a00e24 web: fix lint regression from Load-all — lift sweep state into useDashboardSessions
The Load-all button reconstructed 'sweep running' locally via a
set-state-in-effect on loadingMore, adding a 116th react-hooks warning
over the 115 cap. The hook already knows loadMore vs loadAll apart, so
expose loadingAll from useDashboardSessions and drop the local state and
effect entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:39:51 +02:00
Oliver Kieselbach 90294455e0 added "Load all" link 2026-08-06 14:26:11 +02:00
Oliver Kieselbach 4def73f193 rules: reserve the built-in rule-ID namespace against tenant custom rules
Tenant rule IDs were free-form with only a point-in-time existence check,
so a customer could occupy a gap or retired ID like ANALYZE-SEC-002; a
later built-in with that ID would then silently shadow the tenant's rule
at merge time (global wins, tenant copy skipped) and their RuleStates
would start toggling our rule.

Enforcement: RuleIdPolicy (Shared) reserves (ANALYZE|GATHER)-<CAT>-<NUM>
case-insensitively, with the CUSTOM category as the sanctioned tenant
namespace (we never ship built-ins in a category named CUSTOM). Both
rule services reject reserved IDs on create AND on the custom-branch of
update (updates upsert, so PUT was a bypass); update endpoints now map
InvalidOperationException to 409 like create. Web forms mirror the check
inline; MCP validate_rule emits a warning finding and the authoring
guide now documents the reservation (guide templates moved to
GATHER-CUSTOM-101/ANALYZE-CUSTOM-101 — the old numeric examples would be
rejected). No existing prod custom rule violates the pattern (verified
against all 13 tenant-partition rows), so nothing needs grandfathering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:15:43 +02:00
Oliver Kieselbach 6bab2d3db9 updated command 2026-08-06 13:53:56 +02:00
Oliver Kieselbach 78b8dd0f1b tests: fix ContinuationToken key-rotation flake — scope the test key to the async context
The rotation test swapped the process-wide static test signing key
mid-test; xUnit runs test classes in parallel, so any pagination test
encoding/decoding during that window failed with bad_signature instead
of its expected reject reason (seen as order-dependent one-off failures
in CI). New AsyncLocal-scoped override confines the swap to the
rotation test's own execution context; the process-wide TestSetup
default is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:25 +02:00
Oliver Kieselbach 14acfd5c59 rules: renumber Secure Boot rule to ANALYZE-SEC-008 — SEC-002 is a retired ID
ANALYZE-SEC-002 was the sunset AutoLogon rule; BuiltInRulesTests pins
that the ID never returns (the CI failure on main). Reusing a retired
ID would also inherit any historical tenant state keyed to it. Rule
content is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:16 +02:00
Oliver Kieselbach 22d4c6c677 updated commands 2026-08-06 13:30:29 +02:00
Oliver Kieselbach 9fce499f4c rules: ANALYZE-SEC-002 — warn when Secure Boot is disabled
Fires on secureboot_status.uefiSecureBootEnabled=false; legacy BIOS
devices (registry key absent, agent sets note field) are skipped via
precondition. +20 confidence on physical devices. Validated with MCP
validate_rule (0 findings) and test_analyze_rule dry-runs: fired at 90
on a physical SecureBoot:False session, silent on a SecureBoot:True
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:27:19 +02:00
Oliver Kieselbach 8c7a6410d2 web(mcp-usage): mobile-friendly layout for admin + reporting sections
Headers stack below sm, summary cards go 2-up on phones, top endpoints
show tool name with route as secondary line, and the top-users table is
replaced by stacked rows so requests/last-active are no longer cut off
on narrow screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:01:10 +02:00
Oliver Kieselbach c158768343 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-05 22:55:50 +02:00
Oliver Kieselbach 75197a4184 Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands

* feat(backend): logparser pattern test endpoint with agent-exact semantics

POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex
against pasted sample lines exactly the way the agent would on the device.
Motivated by a field incident: a customer verified a pattern with a PHP regex
tester and the .NET engine matched differently.

- CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent
  (IME tracker, logparser collector, stall probe, IME matching mode) and
  backend run the SAME parsing implementation. Pure mechanical move, agent
  behavior unchanged (V2.Core suite 2147/2147 green).
- TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled,
  1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group
  "0" excluded / unsuccessful groups omitted, format cmtrace (default, match
  against parsed message) vs text (raw line), per-line outcomes incl.
  parse_failed/regex_timeout, and the agent's "set parameter format=text"
  hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line.
- Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run).
- Tests pin the parity showcases: case-sensitivity, envelope invisibility in
  cmtrace mode, optional-group omission, format hint, request validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): test_log_pattern tool + logparser lint and guide hardening

- test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a
  logparser regex against pasted sample lines with the agent-exact .NET
  semantics (case-sensitive, cmtrace/text mode). Description steers clients
  away from JS/PHP/Python regex testing.
- validate_rule: logparser parameter lint (missing/non-compiling pattern,
  unknown format value = silently cmtrace on the agent) + info pointing to
  test_log_pattern; nextStep for valid logparser drafts routes there too.
- rule_authoring_guide: logparser section now states the matching semantics
  (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new
  pitfalls (engine mismatch, plain-text log under cmtrace default).
- Catalog snapshot + handler/validator tests extended (MCP suite 532 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:48:10 +02:00
Oliver Kieselbach 179a441e64 updated commands 2026-08-05 20:21:53 +02:00
Oliver Kieselbach acc95471a6 bootstrap(dev): Pester-testable structure + guard/relax test suite + CI
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.

Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.

Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:17:10 +02:00
Oliver Kieselbach 29ced81d6e docs(okf): tool-telemetry — record the real SDK 1.30 rejection shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:15:09 +02:00
Oliver Kieselbach 4966d23a46 fix(mcp): rejection sniffer must match the isError-result shape, not a JSON-RPC error envelope
Second live miss, root cause found via end-to-end repro against the real SDK
stack: SDK 1.30 catches every McpError around the tool handler (Zod input
validation, unknown/disabled tool) and answers with a SUCCESSFUL JSON-RPC
response whose result is a CallToolResult { isError: true, text: "MCP error
<code>: ..." } — a JSON-RPC error envelope never exists for these. The sniffer
now recognizes both shapes and extracts the code from the text prefix; handler
soft errors (toolError) never carry that prefix, so they are not double-logged.
Repro verified: the sniffer logs tool_call_rejected against a live
express + StreamableHTTPServerTransport + zod-tool stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:14:21 +02:00
Oliver Kieselbach 59ba8301a8 fix(mcp): rejection sniffer must capture res.write chunks, not only res.end
Live verification caught the miss: tool_call and search_zero_hit lines arrived,
tool_call_rejected did not. SDK 1.30 bridges the web-standard Response through
Hono's getRequestListener, which pipes ReadableStream bodies as Uint8Array
chunks via res.write followed by a BARE res.end() — the end(chunk) inspection
never saw the envelope. The sniffer (now attachToolCallRejectionSniffer in
telemetry.ts, unit-testable) wraps both write and end, reassembles up to 10 KB
and decodes string/Uint8Array chunks; tests pin the streaming path, the direct
end(string) path, the over-cap silence and the disabled no-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:05:22 +02:00
Oliver Kieselbach b142fa0994 updated bootstrapper for W365 devices 2026-08-05 19:49:39 +02:00
Oliver Kieselbach afa8750cbb feat(mcp): tool-call quality telemetry — soft errors, result size, zero-hit searches, arg rejections
withToolTelemetry now receives the tool args and inspects the RESULT: handlers
never throw (toolError returns isError:true), so the previous isError flag was
effectively always false. Each MCP_TOOL_LOGGING line now carries soft-error
status, resultChars + overCap (vs. the anthropic/maxResultSizeChars hint),
a size-bounded args summary and the caller scope — the only telemetry that
covers backend-free tools (search_docs, validate_rule, get_resource).

New signals: 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 since the SDK answers those before any handler runs.

Bicep default toolLoggingEnabled=true (desired state; live flag via
az containerapp update). Docs: docs/mcp/tool-telemetry.md with KQL examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:16:35 +02:00
Oliver Kieselbach 3c1229a301 MCP rule authoring: deterministic context, validate_rule, and session dry-run (#124)
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)

POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.

- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
  evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
  with the production path but evaluate ALL conditions instead of breaking on
  the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
  GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
  structural draft validation (unknown source/operator, correlation without
  joinField, invalid regex, non-evaluable confidenceFactor DSL) with
  actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
  side-effect freedom (VerifyNoOtherCalls), draft validation matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): rule-authoring resources — guide, schemas, guardrails

Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve

- rule_authoring_guide: hand-written, complete authoring knowledge (collector
  types, triggers, condition sources, operators, confidence model incl. the
  exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
  matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists

Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt

Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.

- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
  the baked schemas, guardrail pre-flight mirroring the agent matching
  semantics (hive-stripped segment-bounded registry/file prefixes,
  whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
  blocks), and semantic lint for schema-valid-but-dead rules (unreachable
  threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
  not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
  outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
  the {{token}}-interpolated explanation/remediation preview via the
  existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
  -> dry-run.
- Both tools are visible to every role (backend enforces
  TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): rule authoring surface concept doc

docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.

Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:24:28 +02:00
Oliver Kieselbach b4e409783e updated commands 2026-08-04 20:23:21 +02:00
Oliver Kieselbach eb94c612e7 fix(agent): gather phase/event triggers follow the engine-reduced timeline
phase_change/phase_exit/on_event gather triggers (and the phase-scope
gates) were driven by the pre-reduce SignalIngress.SignalPosted stream,
so any raw signal carrying an EspPhase payload fired them before the
decision engine had applied its gates. In session 32312a32 (RealmJoin
tenant) 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) - and read
HKLM\SOFTWARE\RealmJoin\Custom\BIOS before the RealmJoin package that
creates it had run (exists=false).

GatherRuleExecutorHost now subscribes to the new TimelineEventStream:
EventTimelineEmitter publishes every emitted event's (eventType, phase)
post-reduce, so phase triggers fire exactly when the UI timeline shows
the phase, and on_event sees engine-emitted event types
(enrollment_complete, phase_transition, realmjoin_resolved, ...) that
never existed on the raw signal stream - restoring V1 semantics and the
documented "On Event with enrollment_complete" pattern. Raw-signal
consumers with OS-state semantics (DeviceInfoHost, UserEspKeepAwakeHost,
ProvisioningPackageHost) stay on the raw feed deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:44:35 +02:00
Oliver Kieselbach ed5fcf3dc3 chore(deps): bump transitive npm deps to resolve Dependabot alerts
- mcp: hono 4.12.31->4.13.0 (ReDoS in CORS middleware), fast-uri
  3.1.4->3.1.5 (host confusion), ip-address 10.1.0->10.4.0 (SSRF
  bypasses), postcss 8.5.21->8.5.25 (sourceMappingURL file read)
- web: postcss 8.5.22->8.5.25, brace-expansion (DoS, npm audit)

Lockfile-only changes within existing semver ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:57:04 +02:00
Oliver Kieselbach 9ba67162aa fix: session duration inflated by inherited tz-skewed IME log timestamps (#123)
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.

Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
  DO-collector poll path passes null and the adapter stamps the clock
  instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
  earliest-event timestamps more than 2h before the current anchor
  (MaxStartedAtBackwardShift), on all four mutation sites incl. the
  SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
  and honor the writer-declared offset instead of failing to match.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:54:41 +02:00
dependabot[bot] efc96258d6 chore(deps-dev): bump the web group (#122)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `@types/react` from 19.2.17 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 11:19:47 +02:00
Oliver Kieselbach 9d8b0fc254 feat: tenant-config field schema - MCP writes get types right on the first attempt (#121)
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config
had to guess field names and JSON types from a config read. New reflection-
generated schema surface makes guessing unnecessary:

- Backend: GET config/fields-schema (GlobalAdminOnly) - built from the
  TenantConfiguration model + the patch service deny-lists at runtime, so it can
  NEVER drift from what the patch endpoint enforces. Per field: camelCase name,
  JSON type (string/boolean/integer/number), nullability (null clears), format
  hints (json / date-time), writability + deny reason, GA-only marker (phase 2),
  revert-protected marker. Literal route; FindPolicy literal-over-template
  precedence pinned by test (same mechanism as config/all).
- MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config
  description + GA instructions headline steer the model to call it before
  composing a patch.
- Tests: drift canary (every model property appears exactly once), deny-list
  consistency, type exemplars incl. the decimal SLA column class, policy +
  route-precedence pins, MCP catalog/registration/request-shape. Backend 3854
  green, vitest 465 green, tsc clean.

Deploy order: backend first, then MCP redeploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:33:04 +02:00
Oliver Kieselbach f3028041af feat(web): tenant list shows plan badge (Community/Pro) instead of Preview
The green "Preview" badge was the activation-gate whitelist marker from the
preview era. With public availability + auto-approve live it carried no
information. Rows now show the plan tier (legacy "enterprise" resolves to
Pro, everything else Community); the amber "Waitlist" badge still appears
for unapproved tenants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:50:56 +02:00
Oliver Kieselbach 3628c7bc79 fix(backend): revert rehydration - whole-valued double columns must not become Int32
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed
fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish
95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns
(decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble
throws on the type mismatch. The rehydrator now forces GetDouble for columns the
model declares as decimal/double/float, mirroring the existing DateTime handling.

The roundtrip canary test is hardened into a reflection filler that sets EVERY
writable property non-default (with a deliberately whole-numbered decimal), so no
column type can escape the fidelity check again; plus a focused pin on the exact
prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused
the write, the snapshot itself is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:45:11 +02:00
Oliver Kieselbach 395fdd49df Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first

New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.

Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.

Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): transactional tenant-config field patch + revert with verified writes

New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
  fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
  auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
  shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
  verify that EXACTLY the intended fields changed -> automatic ETag-conditional
  rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
  (latest by default); snapshots current state first (revert is revertible),
  preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.

Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes

First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
  reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
  required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
  current unless includeProtectedFields; revert is itself revertible

New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window

The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:30:05 +02:00
Oliver Kieselbach 5edb64f3c9 fix(web): confirm-dialog errors surface in-dialog + dark-mode sheet gaps
The GA offboard and app-homing confirm dialogs reported failures via the
admin page-level error banner, which sits behind the tenant editor modal
(z-50) and the dialogs themselves (z-[60]) - the user saw nothing. Both
dialogs now take an error prop and render the failure inline; the section
routes homing/offboard failures into dedicated states cleared on open.

Dark mode: class-by-class audit of the offboard UI against the global
override sheet. The dialogs were already covered; the real gaps were
missing family members in globals.css - border-{red,amber,green,purple}-100,
border-{blue,sky,yellow,rose,emerald}-300, and text-red-600/700 (mapped to
red-400, mirroring the cyan/rose 600-to-400 convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:56:17 +02:00
Oliver Kieselbach 46965af290 feat(backend): finalize + arm the offboarding farewell email
Replace the [DRAFT] placeholder with the final copy (thank-you, gentle
feedback pointer to GitHub/LinkedIn, welcome-back note, last-email notice)
and remove the OffboardFarewellEmailArmed scaffolding gate. Tests move from
the disarmed-by-default contract to the armed contract (gate ordering,
never-throw, template pins). GitHub links in both email templates now point
to the renamed repo okieselbach/AutopilotMonitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:20:27 +02:00
Oliver Kieselbach 1bfdb68ff6 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-02 18:02:08 +02:00
Oliver Kieselbach 5ee5114d5c updated modification datetimes page 2026-08-02 18:02:04 +02:00
Oliver Kieselbach a7bb7ebf15 feat: Pro requires a tenant contact address - gated at plan entry, never as a runtime lockout
Backend: POST config/{id}/trial now denies with 409 ContactEmailRequired
when no contact address is stored (extracted EvaluateTrialStart verdict,
order test-pinned: consumed/already-Pro win over the contact prompt). The
gate is live at the API even while the trial CTA stays behind its
"coming soon" flag; GA plan assignment stays unblocked by design. The
feature-flags payload gains contactEmailSet (bool only, never the
address - contract test updated).

Web: trial CTA shows a "set a contact address first" note instead of the
start button when the flag flips on; dashboard gets an amber non-blocking
banner for Pro tenants without a contact (admins only, fail-safe: only an
explicit contactEmailSet:false nags, so loading/error/older-backend states
stay quiet); GA tenant editor warns when assigning Pro to a tenant
without a contact address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:58:49 +02:00
Oliver Kieselbach a7a1cc43c1 fix(backend): welcome mail survives the auto-approve race - send-on-save + once-only marker
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.

Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:33:59 +02:00
Oliver Kieselbach 2a5ad65dd7 fix(web): explicit JSX spaces where Turbopack eats them - "onlyto" class of typos
Turbopack drops the ambient space between an inline element and adjacent
prose in certain multi-line JSX text layouts (Babel/SWC preserve it), so
the live portal rendered "onlyto reach you", "IsEnabledflag",
"...ps1script", "5 minutesfor GitHub's", "Unavailableand the cascade".
User-reported on the Contact section; the other five sites were found by
scanning the compiled bundle for space-less text nodes after elements.

Fix: explicit {" "} at each affected boundary - it compiles to its own
text-node and cannot be trimmed. Verified in a fresh build: all six sites
now emit ..."})," ","to reach... Sites whose gap comes from ml-*/mx-*
margins or flex gap were left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:31:04 +02:00
dependabot[bot] 960b3d8ed9 chore(deps): bump github/codeql-action from 4 to 4.37.3 (#119)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 16:52:33 +02:00
Oliver Kieselbach 86bb255b11 feat(web): GA offboard button in tenant management - with type-to-confirm and ban-semantics note
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.

Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:48:53 +02:00
Oliver Kieselbach d3a10dfb96 fix(backend): tenant activation is race-proof - conditional insert dedupes welcome mail and ops event
Double signup on two instances enqueued two auto-approve envelopes; both
workers could pass the approved-check (stale 30s negative cache) and both
ran the activation side effects - duplicate welcome mail plus duplicate
TenantAutoApproved ops event/Telegram ping. Same duplicate on queue
redelivery after a failed message delete.

The whitelist add is now a conditional INSERT (AddEntity, 409 = already
approved) so storage arbitrates concurrent activations: exactly one caller
sees "newly added" and runs mail/promote/ops event; losers drop quietly.
The GA approve endpoint stays idempotent (200 + alreadyApproved instead of
a duplicate mail on re-approve).

Also fixes a silent-failure path: the repo swallowed storage errors and
returned false, which ApproveAsync ignored - a failed whitelist write
still sent the welcome mail and reported success. Storage errors now
throw (queue path retries, HTTP path 500s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:32:13 +02:00
Oliver Kieselbach 80d0b30cba fix(backend): activation-gate review findings - OnboardedBy immutable via PUT, suspension gate reads fresh
Two findings from the re-run public-availability review:

- UpdateTenantConfigurationFunction preserved HomedAppClientId, LastAuth*
  and plan fields against round-trip clobber but not OnboardedBy, despite
  AuthFunction documenting it as write-once: any own-tenant admin PUT could
  overwrite or null it, corrupting audit provenance and letting a
  revoke -> re-approve cycle auto-promote an attacker-chosen UPN. Now
  preserved like the other system-owned fields.

- TenantAutoApproveHandler checked the suspension gate through the cached
  TryGetConfigurationAsync (5-min per-instance TTL): a tenant suspended
  right after signup could still be auto-approved from a stale view. Now
  reads via GetConfigurationFreshAsync; a storage error propagates so the
  message retries (flag re-checked per attempt) instead of silently
  downgrading the tenant to manual approval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:13:31 +02:00
Oliver Kieselbach aee723e497 fix(backend): app-homing flip reads config fresh - stale cache could rewind concurrent writes
FlipAsync read the tenant config through the cached GetConfigurationAsync
(5-min per-instance TTL, fail-open to CreateDefault) and saved the whole
entity back: a flip could silently rewind fields another writer changed
within the TTL, and a transient storage read error would have persisted a
default config over the real row. Now reads via GetConfigurationFreshAsync
(cache-bypassing, errors propagate) and aborts loudly when no config row
exists instead of materializing a default one.

Daily-review finding 2026-07-31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:01:25 +02:00
Oliver Kieselbach 427c923530 feat(web): mobile menu for landing navbar - sign in was unreachable on phones
Below md the landing navbar only showed the Get started CTA: Sign in was
hidden sm:block and the nav links (Product/Capabilities/Compare/Docs) had
no mobile counterpart at all. Returning users on a phone had no visible
way to log in.

- burger toggle (<md) opens a solid panel under the bar: nav links,
  GitHub, and a prominent Sign in button; closes on link click, backdrop
  tap, and Escape
- Get started stays always visible in the top bar; GitHub icon and Sign
  in text button move into the menu below md
- panel uses opaque --lp-surface: nested backdrop-blur inside the nav
  does not blur page content, a translucent panel let the hero bleed
  through
- 320px fit: px-4 below sm and the wordmark hides below 360px (mark only)

Verified via Playwright at 320/390/1280px: menu open/close paths, anchor
scroll to #features, desktop layout unchanged; tsc and ESLint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:12:34 +02:00
Oliver Kieselbach b46a8f1571 fix(rules): merge collision guard - stale tenant rule copies no longer crash rule notifications
Tenants seeded in the pre-global-partition era can still carry per-tenant
copies of built-in rules (IsBuiltIn=false debris, e.g. ANALYZE-ID-002 on one
prod tenant). They pass the custom-rule filter and put the same RuleId twice
into the merged list, blowing up ToDictionary(r => r.RuleId) in
AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync and silently
dropping rule channel notifications for that tenant.

Guard the merge in AnalyzeRuleService + GatherRuleService: a tenant row whose
RuleId collides with a merged global rule is skipped (global wins) with a
warning log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:40:29 +02:00
Oliver Kieselbach 6e294c010e fix(web): validation-gate toggles persist - disable was local state only
Prod report 2026-08-01 (tenant b54dc1af): corporate-identifier validation
"came back on" after every disable. Audit log shows the truth: not a single
True->False entry ever - the disable NEVER reached the server. The validation
section has no save bar, and only the consent-driven ENABLE paths persisted;
the toggle setters (disable via confirm dialog, and enabling the second gate
while the first already carries the consent) wrote plain React state that
silently reverted on the next config load.

New TenantConfigContext.saveValidationGate routes those setter calls through
the shared config PUT with explicit override values; local gate state re-syncs
from the server response (or snaps back on failure), and the section save
indicator covers the write.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 00:32:51 +02:00
Oliver Kieselbach dc54c9555c fix(web): correct Entra roles for admin consent - Application Administrator is not sufficient
The validation info box and the detect-access error still claimed Application
Administrator could grant the consent. Per MS Learn (grant-admin-consent),
Application/Cloud Application Administrator are excluded for Microsoft Graph
app roles (application permissions) - our consent includes the app-only
DeviceManagementServiceConfig.Read.All. Correct roles: Global Administrator
or Privileged Role Administrator, matching the funnel banner and docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:21:28 +02:00
Oliver Kieselbach 784fd99240 fix(web): consent verify rides AAD propagation; funnel banner actions + dark mode
- Post-consent verification now POLLS the access-check (up to 5 attempts,
  15s apart) instead of a single shot: right after a successful admin consent
  the app-only token often mints with an EMPTY roles claim for 30-90s
  (observed live: tenant 5ca2b350, GrantedRoleCount=0 at T+43s), which the
  single-shot probe misreported as "permission not granted" while the homing
  flip and consent were actually fine. The access-check invalidates and
  re-mints fresh per call, so the poll converges as soon as Microsoft has
  propagated the role; the consent spinner stays up meanwhile. Final "absent"
  message now names propagation as the likely cause instead of sounding fatal.
- Funnel banner gains always-visible actions ("Grant consent for the new app"
  + "Detect existing access" on the autopilot gate, idempotent when already
  enabled): tenants whose validations are already ON had no consent button to
  click, and the per-toggle detect link under Corporate Identifier would
  enable that validation as a side effect.
- Dark mode: funnel/flipped/legacy banners and the admins-only notice were
  light-palette-only and unreadable on dark.

tsc clean, vitest 631/631, ESLint ratchet holds (115).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:15:50 +02:00
Oliver Kieselbach 607d6615b0 fix(auth): app-homing flip survives stale full-model config writers
Prod incident 2026-07-31 18:03Z (tenant b54dc1af): the consent-driven homing
flip landed and 600ms later the frontend persisted the validation toggle via
the generic config PUT - round-tripping a config loaded BEFORE the flip. The
GA branch treated the stale null HomedAppClientId as an intentional revert to
legacy, silently undoing the flip (App Insights: AppHomingFlipped 18:03:00.44,
PUT revert warning 18:03:01.03).

- HomedAppClientId is now IMMUTABLE via the generic PUT for ALL callers, GA
  included: a full-model round-trip can never carry flip intent. The only
  writers are the consent auto-flip and POST config/{tenantId}/app-homing
  (which the GA tenant-management panel already uses).
- HandleAuthClientIdTrackingAsync (same failure class, cross-instance): the
  last-seen tracking write now mutates a cache-BYPASSING fresh read via new
  TenantConfigurationService.GetConfigurationFreshAsync instead of blind-saving
  the up-to-5-min-stale cached view - the login right after a flip is exactly
  the one that carries a changed audience and triggers this write.

3 regression tests (fresh-entity mutation preserves a concurrent flip,
fresh-already-current suppresses the write, steady state stays zero-I/O).
Backend xUnit 3767/3767.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:20:19 +02:00
Oliver Kieselbach 8d4f7ea0f9 fix(web): app-homing funnel banner - prominent card with permission hint + docs link
The plain info box was easy to overlook. Upgrade to a bordered gradient card
with icon and heading, state that admin consent needs a tenant-wide-consent
role (Global Administrator / Privileged Role Administrator), and link the
portal-setup docs article describing the required permissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:17:54 +02:00
Oliver Kieselbach 0e2d620ad4 feat(auth): self-service app-homing flip via consent flow + GA lever
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.

- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
  cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
  manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
  primary only, probe-gated; GA gets force + revert. Pure decision helper
  EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
  credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
  (AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
  GA tenant management (typed homedAppClientId hardens the PUT round-trip),
  funnel/success banners in tenant settings with switchAuthApp re-login,
  admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
  events survive; strategic events across the whole flow (app_homing_flipped,
  consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
  auth_app_homing_learned, app_homing_manual_flip*).

Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 19:52:23 +02:00
Oliver Kieselbach 69d61c6c26 feat(backend): dynamic poison-queue enumeration for health + ops monitoring
The static MonitoredPoisonQueues watch-list had already drifted twice
(tenant-auto-approve-poison, session-deletion-maintenance-poison missing).
Health check and maintenance watcher now enumerate every existing *-poison
queue in the storage account at runtime; an absent queue has never had a
poison-move and is healthy by definition, so enumeration covers everything
a list would. Enumeration failure surfaces as warning (never a silent
all-clear) on the health card and skips the maintenance tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:59:21 +02:00
Oliver Kieselbach cfb28d83b6 added png logo 2026-07-31 18:48:12 +02:00
Oliver Kieselbach f795fbcb4e feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).

Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
  CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
  operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
  one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
  am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
  PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
  the acting app per tenant (no-op until the legacy config pair is set); Graph
  client-credentials, admin-consent URLs and the surfaced client id follow the
  homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
  the middleware audience builders (was single-audience - would 401 legacy
  tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
  deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
  mcp-server.bicep default client id -> C4A8 app.

Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:12:25 +02:00
Oliver Kieselbach d9d22bc443 fix(web): drop Beta pills on Submit Logs and Report Session
Feature-maturity badges were a leftover of the preview era — with the product
publicly available, a lone "Beta" pill only signals uncertainty. Both features
are fully functional; the dashboard banner already carries the actively-developed
framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:38:43 +02:00
Oliver Kieselbach 6bd5728652 feat(platform): public availability — tenant activation replaces Private Preview, optional auto-approve
Backend: shared TenantApprovalService (whitelist add + auto-promote shape-guard +
welcome email), AdminConfiguration.AutoApproveNewTenants (default off, uncached
fail-closed accessor), tenant-auto-approve queue (1-min visibility delay, worker
drops on flag-off/approved/suspended/missing config, approves as "System
(auto-approve)" + TenantAutoApproved ops event), 30s negative whitelist cache,
gate 2 error PendingActivation with mode-neutral message, signup strings and
welcome email without preview wording.

Web: /preview → /activation (SWA 301, robots, AuthGate), AuthContext accepts both
error codes and clears pending state on successful auth/me, activation page polls
every 30s and auto-redirects into the portal, dashboard banner reframed to
"Actively developed" + feedback call, terms/privacy/get-started updated, feature
badges → Beta, admin-config web chain + auto-approve checkbox, OPS_EVENT_TYPES.

Deliberately unchanged: PreviewWhitelist/PreviewConfig tables, internal preview/*
API routes, preview_signup notification type, Microsoft DevPrep preview mentions.

Verified: solution build 0 errors, xUnit 3701/3701, tsc 0 errors, vitest 612/612,
ESLint ratchet holds (0/115), next build exports /activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:30:57 +02:00
Oliver Kieselbach d6affae8fc fix(web): plan card — bootstrap bullet wording (run already before MDM enrollment)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:40:56 +02:00
Oliver Kieselbach a70c5de667 feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request
- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy
  stored "enterprise" stays readable as Pro (no data migration); new
  entitlements BootstrapIncluded + UnrestrictedModeAvailable
- TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA
  flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle,
  read-time - trial expiry/downgrade re-arms guardrails fail-closed)
- All bootstrap endpoint gates + SecurityValidator use the effective check;
  /auth/me and feature-flags expose effective availability values
- PlanManagement accepts community|pro; AlreadyPro; Pro trial wording
- Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro
  labels/cards + new Pro feature bullets, effective section gating,
  terms/privacy renamed with Last-updated bump
- Tests: catalog matrix, effective-gate payload tests, helper tests incl.
  trial-expiry re-gate (backend 3692 green, web 612 green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:04:47 +02:00
Oliver Kieselbach e62bac9fea fix(web): hooks-refs batch 2.5 — ProtectedRoute wasAuthenticated ref to state; refs category at zero
wasAuthenticated was written AND read during render (the only such
case). Historical per-mount state belongs in useState, adjusted during
render via the guarded official pattern — behavior-identical: the value
is only consulted in !isAuthenticated renders, which can only follow a
render that already set it. Auth-gate control flow (auto re-login,
loading early-return, post-login return URL) untouched.

react-hooks/refs: 26 -> 0. Lint ratchet 115.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:29:27 +02:00
Oliver Kieselbach 1e94a7aa91 fix(web): hooks-refs batch 2.4 — useDashboardSessions mirror refs via useLatest
Nine render-phase mirror writes replaced: scope/filter/pagination
mirrors sync post-commit via useLatest; consuming callbacks list the
stable refs in their deps. Drops two write-only refs
(joinGlobalAdminsRef, loadingMoreRef) that nothing read. Imperative
refs (fetchGen/fetchLock/loadAllToken/submittedFilter) untouched.
Lint ratchet 119.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:24:32 +02:00
Oliver Kieselbach c330e5c91a fix(web): hooks-refs batch 2.3 — useDashboardStats mirror refs via useLatest
Seven scope mirror refs sync post-commit via useLatest instead of
render-phase writes; fetchStats lists the stable refs it reads in its
deps. Lint ratchet 128.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:19:22 +02:00
Oliver Kieselbach 6015548172 fix(web): hooks-refs batch 2.2 — useFleetHealth mirror refs via useLatest
Four scope mirror refs (routeGlobal, selectedTenantId, tenantId, days)
now sync post-commit via useLatest instead of render-phase writes.
fetchData lists the stable refs it reads in its deps. Lint ratchet
135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:16:10 +02:00
Oliver Kieselbach 3f661d097d fix(web): hooks-refs batch 2.1 — useLatest helper; useAuthenticatedFetch + presence off render-phase ref writes
Render-phase ref mirror writes tear under concurrent rendering
(react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive
effect instead; refs from custom hooks are not linter-known-stable, so
consumers list them in dep arrays (identity is stable — no re-creation).
Lint ratchet 141 -> 139.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:11:59 +02:00
Oliver Kieselbach 3392a23da6 feat(bootstrap): promote dev script v2.2 to stable — download.autopilotmonitor.com endpoint + OOBE-relax (#118)
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.

CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:37:17 +02:00
Oliver Kieselbach 5312216f6b fix(web): hooks-warnings batch 1 — static-components, purity, immutability to zero (#117)
First burn-down batch of the React-Compiler hooks warnings (163 -> 141,
CI ratchet lowered to match).

static-components (14, real remount bugs): SortIcon (geographic-
performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/
ResultsDropdown (GlobalSearch) were defined inside render — a new
component type every render, so React unmounted and rebuilt their
subtrees each time (GlobalSearch tore down its dropdown on every
keystroke). All hoisted to module level with explicit props.

purity (6): Date.now() during render replaced with mount-time clocks
(useState lazy init) for the date-input caps (Maintenance sections) and
trial-expiry checks (TenantManagement, SectionTenantConfigReport);
FeedbackSection refreshes its clock per fetch so "Xm ago" labels track
the data; DownloadProgress falls back to NaN instead of wall-clock for
unparseable event timestamps (rate calc already guards via
Number.isFinite).

immutability (5): fetch functions were declared below the effects that
call them — moved above (diagnosis, fleet-health, DistressReports).
This unblocked deeper compiler analysis, surfacing 3 latent
set-state-in-effect findings in those files (93 -> 96); they belong to
the set-state category and stay for its own batch.

Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean,
vitest 611/611, static export builds.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:21:29 +02:00
Oliver Kieselbach 72392f6f4c chore(web): lint findings to zero errors + CI lint gate (#116)
* feat(web): typed routes — broken internal links are now compile errors

typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): lint findings to zero errors + CI lint gate

Package deal — fixes only land together with the gate, otherwise they
drift right back. CI web job now runs `eslint . --max-warnings 163`:
errors fail outright, the warning cap is a ratchet (lower it when fixing
warnings, never raise it).

Fixed to zero (mechanical categories, behavior unchanged):
- no-unused-vars (49): dead imports/locals removed; where a prop is part
  of a live call-site contract, only the destructured binding dropped
- no-explicit-any (29): real structural types for event payloads
  (EnrollmentEvent.data et al -> Record<string, unknown> + per-event
  interfaces), SignalR passthroughs typed via the library's own
  signatures; five consumer files got typing-only follow-through
- no-unescaped-entities (15), prefer-const, jsx-no-comment-textnodes
- no-require-imports (5): scripts/**/*.js override — Node CJS scripts,
  require() is correct there
- one stale eslint-disable directive removed

Deliberately NOT fixed inline: the React-Compiler hooks rules new in
eslint-plugin-react-hooks v6 (set-state-in-effect 93, refs 26,
static-components 14, purity 6, immutability 5,
preserve-manual-memoization 3) are demoted to warn in eslint.config.mjs —
each is a per-site behavioral refactor in auth/SignalR-adjacent code, to
be burned down incrementally under the ratchet.

Verified: eslint 0 errors / 163 warnings (gate command exits 0), tsc
clean, vitest 611/611, static export builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:38 +02:00
Oliver Kieselbach d65157a092 feat(web): typed routes — broken internal links are now compile errors (#115)
typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:55:26 +02:00
Oliver Kieselbach cf45911b69 Web upgrade: Next 16 + React 19 + MSAL v4 (#114)
* chore(web): bump to next 16, react 19, msal-browser 4, msal-react 3

react 19.2 aligns the declared dependency with the React the app router
has been running all along (Next-vendored 19 canary). msal-react 3.0.29
is the React-19-compatible bridge that only needs msal-browser 4 —
deliberately NOT msal v5, whose COOP redirect bridge requires new
bridge pages per origin plus app-registration changes (separate
project). The react-leaflet peer overrides are gone: react 19
satisfies react-leaflet 5 natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): react 19 / msal v4 / next 16 code adaptations

- msalConfig: allowNativeBroker -> allowPlatformBroker (v4 rename);
  storeAuthStateInCookie stays (deprecated in v4, removed only in v5)
- zero-arg useRef() removed in @types/react 19: GlobalSearch (2x),
  dashboard autoLoadTimerRef
- React.MutableRefObject -> React.RefObject in 5 session/progress hooks
  (all refs are mutable in 19 types)
- layout: data-scroll-behavior="smooth" opt-in so route changes keep
  jumping instead of animating (Next 16 stopped suppressing CSS
  smooth-scroll during navigation)
- next.config: analyzer comment - Turbopack default build skips the
  webpack-only analyzer, use --webpack for ANALYZE runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): untrack Next-managed next-env.d.ts, generate types in CI

Next 16 rewrites next-env.d.ts on every dev/build run and the routes
import inside flips between .next/types and .next/dev/types (vercel/
next.js#86001) - official guidance is to gitignore it. CI now runs
`next typegen` before tsc so the type stubs exist on a fresh checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): migrate lint script to eslint CLI (next lint removed in 16)

Minimal flat config from eslint-config-next@16 (core-web-vitals +
typescript) with build-output ignores. Lint stays out of CI as before;
the 264 pre-existing findings are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:54:12 +02:00
Oliver Kieselbach 1593232898 fix(web): every nav click was a full-page reload — skipTrailingSlashRedirect broke RSC fetches
skipTrailingSlashRedirect (added for the next-dev /api proxy 308) also defines
__NEXT_MANUAL_TRAILING_SLASH, which disables the client router's trailing-slash
normalization. Under output:'export' the router then requested RSC payloads as
/<path>.txt while the build only emits /<path>/index.txt — every fetch 404ed and
Next silently degraded EVERY <Link> navigation to an MPA (full-page) load. Most
visible symptom: the sidebar remounted on each click and its expanded groups
collapsed.

- next.config.ts: dev-gate skipTrailingSlashRedirect (prod build normalizes
  links to /foo/ again; RSC fetches hit the emitted index.txt files)
- GlobalSidebar: normalize usePathname() (client nav now yields /foo/, hard
  loads /foo — exact matches against the slash-less nav hrefs need both), and
  re-run the auto-expand effect when visibleExpandableGroups arrives so a hard
  load (deep link, F5) still expands the active group once MSAL resolves

Verified: static export + local serve, marker survives Link nav (no document
request, /terms/index.txt 200); tsc clean; vitest 611/611.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:03:52 +02:00
Oliver Kieselbach e4c06a9564 Landing page v2 — scroll story, portal-first hero, Autopilot Monitor rebrand (#113)
* feat(web): landing page v2 — scroll story, green brand, Autopilot Monitor wordmark

Complete rebuild of the landing page as a scroll story of one enrollment:
- Hero shows the product live (animated event-stream timeline, real event vocabulary)
- Story acts: 09:02 power-on -> 09:41 ANALYZE-ESP-001 hit (most-firing rule, ~22% hit rate)
  -> 09:43 MCP terminal demo (full session report via get_session_summary +
  get_time_attribution) -> fleet zoom-out
- Floating pill navbar, capabilities strip, restyled comparison, shared SiteFooter
  (now also mounted on /about, /terms, /privacy which had no footer at all)
- Green mark (#33B161) unified as the only brand mark via BrandMark.tsx;
  blue-indigo gradient marketing mark removed
- Wordmark is now "Autopilot Monitor" (two words) across all user-visible text,
  metadata and JSON-LD; technical identifiers (script names, ProgramData paths) untouched
- Theming via lp-* CSS variables on :root/.dark — new components never collide with
  the global .dark !important overrides; old .landing-page block kept for /preview
- StatsBand replaces PlatformStats: adds success rate (successfulEnrollments payload
  field, previously unused) and a loading skeleton instead of fake defaults
- New opengraph/twitter/apple images in the new design (green mark on dark slate)
- All animations hand-rolled (rAF/CSS/IntersectionObserver), CSP-safe, zero new
  dependencies, prefers-reduced-motion respected; auth path (AuthGate/LoginButton/
  hostRouting) and staticwebapp.config.json unchanged

Checks: tsc clean, vitest 611/611, static export build clean, no horizontal
overflow at 375px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing feedback round — linear story timeline, honest examples, dev stats

- Story is now one vertical timeline with a strict top-down reading order
  (time rail left, text left, visual right) instead of the zigzag grid;
  act 1 gets a "session appeared" card so no column is left empty
- Hero live session decoupled from the story device (CONTOSO-0912/Surface
  Laptop 7 completes; the story's CONTOSO-4711 is the one that gets stuck)
- Defender for Endpoint removed from the demo stream (it is onboarded, not
  installed during ESP) — replaced with Microsoft 365 Apps
- StatsBand: sample numbers in local dev only; in production the band shows
  real figures or hides entirely on fetch failure (never fake numbers,
  never an endless skeleton)
- Capabilities copy: "Pull a diagnostics package" instead of the incorrect
  "Pull ETL and event logs"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v3 — mission-control cockpit instead of single-session card

The first screen now shows the product as a live fleet cockpit: KPI strip
(active-now count ticks), five enrollments running concurrently with
advancing progress bars, a new session popping in, two completing during
the ~23s loop, and the analysis engine visibly flagging the stuck device
inline (HIGH ANALYZE-ESP-001 with the culprit app). Event stream column on
desktop. Headline block is left-aligned and more compact to break the
centered-template look. Deterministic single-tick choreography,
IntersectionObserver start, prefers-reduced-motion gets a rich static
frame. Replaces LiveTimeline.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v4 — real portal screenshots instead of fabricated cockpit

Hero visual is now the actual product: the live portal dashboard (captured
via Playwright from portal.autopilotmonitor.com, demo fleet staged in the
DOM — no customer data, preview banner removed, wordmark as it ships) with
a real analysis finding card (ANALYZE-APP-002 incl. remediation + evidence
JSON, from the docs assets) overlapping bottom-right. Bonus asset
time-attribution.png captured for later use. Removes HeroCockpit.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v5 — clean dashboard, real-analysis terminal, refined header

- Analysis-finding overlay removed: the dashboard shot stands alone (was
  colliding with the stats band and not catching)
- Terminal demo moved into the hero as its own "Then just ask" section and
  rescripted from a real production analysis (anonymized): failed attempt
  with 0x8007EA61 decoded to unmapped installer exit code 60001 (PSADT),
  transient proof via clean retry, actionable recommendation
- Headline block refined: uppercase eyebrow instead of pill badge, tighter
  type and buttons
- Wordmark "Monitor" back to ink color (green variant parked for compare)
- Story act 3 now shows the real time-attribution screenshot instead of
  duplicating the terminal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): story act 3 — notification + one-click diagnostics instead of cropped screenshot

"See where the minutes went" (half-cut time-attribution raster) replaced
with the admin's real next move: the Teams/webhook alert that fired with
the rule, and the on-demand diagnostics flow (requested -> collected
mid-enrollment -> ZIP ready). Crafted JSX cards consistent with the other
acts; unused time-attribution.png removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hero v6 — portal-first layout from approved design draft

- Full-width sticky navbar (brand, Product/Capabilities/Compare/Docs,
  Sign in + Get started) replaces the floating pill
- Hero per draft: gray eyebrow, "See every Autopilot enrollment. Live.",
  sub left / CTA group right with trust line, portal shot nearly full
  width (max-w-7xl), green background glow removed
- Stats as a full-bleed band with dividers under the shot; "issues
  detected" replaces success rate (underlines automated analysis),
  events compact-formatted (8.3M)
- "Then just ask" terminal extracted into its own JustAsk section after
  the band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): get-started flow page, honest Teams card, calm buttons, light-only landing

- New public /get-started page: five-step schematic (sign in -> request
  access -> consent -> deploy bootstrapper -> watch live); "Get started"
  in navbar and hero now lead here, "Sign in" keeps the login handoff —
  resolving the two-identical-CTAs problem. Wired into hostRouting
  PUBLIC_PATH_PREFIXES, sitemap, robots allow-list.
- StoryAction Teams card corrected against the backend renderer: the real
  adaptive card has exactly one action ("Open session") — the invented
  "Collect diagnostics" button is gone (NotificationAlertBuilder.cs:60).
- Buttons no longer jump (translate-y removed); hover is brightness +
  shadow only. Final CTA rebuilt as a calm two-column band per draft
  (no dark box, no glow).
- Landing/marketing surface is light-only for now: lp-* tokens re-declared
  under .dark .landing-v2, theme toggle removed from LandingNavbar
  (screenshots are light; portal keeps full dark support).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): about/terms/privacy on the landing shell — no more sidebar flash

The three public pages now render exactly like /get-started: LandingNavbar
+ landing-v2 tokens + eyebrow/headline header + SiteFooter. GlobalSidebar
no longer treats them as sidebar pages for anonymous visitors — that was
the "weird loading": app sidebar + PublicSiteNavbar popping in after
hydration on marketing pages. PublicSiteNavbar and PublicPageHeader are
dead and deleted; terms/privacy links switched from indigo to the accent
green. Authenticated users keep the portal chrome on these pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): landing navbar anchors root-anchored so they work from subpages

#story/#features/#comparison -> /#story/... — clicking Product/Capabilities/
Compare on /get-started (or any public subpage) now navigates back to the
landing section instead of dead-ending on a missing local anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): calmer CTA green, about de-blued, public-page typography aligned

- All real CTAs (navbar, hero, final CTA, get-started, story mock button)
  switch from bright #33b161 to the deep accent-ink green — matches the
  dark-green family in the portal screenshot, less loud
- About page fully on the new design language: rainbow colorMap collapsed
  to the single green accent, audience cards/step bubbles/links/quick-link
  hovers de-blued
- About/terms/privacy typography scaled to landing sizes: 15px body,
  smaller page h1s and section headings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terms callouts on lp tokens — no more stray blue/amber banners

Info callout ("Who can use it") now uses the green accent-soft style;
the AS-IS warning uses the palette warn tones. Verified terms/about carry
no remaining raw blue/amber/indigo utility colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about tech stack — drop version numbers, fix stale SSR claim

Versions age silently on marketing pages (.NET 10 / Next.js 15 / React 18
removed, technologies stay). Also corrects "server-rendered React app" —
the portal has been a static export since the RSC-prefetch incident fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): terminal demo app renamed to Contoso CRM App

Neutral fictional app instead of a vendor-adjacent print driver name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): analysis card confidence score, 40+ rules claim, final CTA to /get-started

- StoryAnalysis card gets the portal-style confidence bar (80%)
- Act 2 copy: "40+ built-in rules" (exactly 40 in rules/analyze today)
- "Start monitoring now" leads to /get-started like the other Get-started
  CTAs; sign-in stays in the navbar and on /get-started itself

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal brand aligned to landing, subtle Enterprise anchoring

- Portal navbar (both variants) now uses BrandMark + the landing wordmark
  style (w-6 mark, 15px bold tracking-tight, strong ink in light/dark) —
  completes the brand consolidation follow-up
- Enterprise plan anchored softly, free-first tone preserved: final-CTA
  subline + /get-started closing note ("same service, nothing to
  migrate") + "Plans" link in the footer Product column, all pointing to
  the docs plans page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): final CTA right-aligned, get-started enterprise note "and more"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social images refreshed to current hero claim and tagline

"See every Autopilot enrollment. Live." + the site tagline (real-time
monitoring, automated analysis, AI-ready telemetry) on the green-mark
dark card; opengraph + twitter regenerated at 1200x630.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): social card wordmark single-color, matching the site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal sidebar selection states green — brand-aligned like the docs

Active nav items, expandable group actives, page-section title/divider
switch from blue to the green brand accent (light + dark variants).
Purple Global Admin mode stays purple (semantic distinction); broader
portal blues (status pills, buttons, KPI tiles) deliberately untouched —
that is a separate re-theme pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview feedback round — dark-mode leak, alignment, renamed repo links

- About/terms/privacy no longer render half dark for dark-mode visitors:
  the light-only marketing scope now neutralizes the global .dark utility
  overrides (bg-white / text-gray-* / border-gray-* / gray hovers) inside
  .landing-v2
- All landing sections aligned to one container width (max-w-7xl): story,
  just-ask, capabilities, comparison, how-it-works now share the same
  left edge as hero, stats band and final CTA
- GitHub links updated to the renamed repository okieselbach/AutopilotMonitor
  (old hyphenated URLs redirected, now correct at the source)
- /get-started wording: "Preview" instead of "Private Preview" (legal pages
  and portal status texts intentionally unchanged)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): preview page on the new design + ?demo=1 review mode; footer width aligned

- /preview rebuilt on lp tokens: BrandMark, single wordmark, calm card,
  accent notify box, neutral next-steps, ghost actions — "Preview"
  wording instead of "Private Preview"
- ?demo=1 skips the auth redirects and renders placeholder identity so
  the page can be reviewed unauthenticated (locally and on SWA previews)
- SiteFooter container widened to max-w-7xl to match navbar and sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): preview page — prominent approval-pending badge, honest status copy

- Amber "Approval pending" status pill above the headline
- Next-steps wording per review ("sign out and wait a bit or contact
  me…", capacity clause dropped)
- Corrected the false "approval status on your dashboard" claim: a
  blocked tenant never reaches a dashboard — signing in again lands back
  on this page while pending, and goes straight into the portal once
  approved (verified against AuthContext PrivatePreview handling)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): about/terms/privacy cards flattened to the landing pinstripe style

All raised cards (shadow / shadow-sm) become flat lp-surface panels with
the thin lp-line-soft border; inner plan boxes and quick-link tiles
switch from gray-200 borders to the same pinstripe line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal primary actions green — buttons, avatar, Progress Portal icon

- Primary buttons (bg-blue-600/indigo-600 + hover pairs) switch to
  green-600/700: admin backups, customs archive, vulnerability data,
  session reports, and the settings SaveResetBar
- Navbar user avatar circles green instead of blue (all four variants)
- Progress Portal sidebar MonitorIcon gets a deliberate green accent
  (light + dark) instead of inheriting gray
- Semantic blues (status pills, info banners, KPI tiles) untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): portal links and focus rings on brand green

- All focus states unified: focus:ring/border blue-500 and indigo-500
  (207 occurrences) -> green-500 across inputs, selects, buttons
- Link patterns text-blue-600/indigo-600 with hover 800, hover:underline
  variants (36) -> text-green-700 hover:text-green-800; icon hovers
  hover:text-blue-600 (12) -> green
- New dark override .dark .text-green-700 -> green-400, mirroring the
  existing blue-700 convention
- Semantic blues untouched: info banners, status pills, KPI tiles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): local dev sign-in repaired; progress portal brand accents green

Local sign-in appeared broken (always landed on /progress as a role-less
user) — two stacked regressions in the dev API proxy:
1. trailingSlash:true (static-export change) made next dev 308-redirect
   /api/* to /api/*/ BEFORE the proxy rewrite; the Functions backend
   rejects trailing-slash routes, so /me silently failed.
   -> skipTrailingSlashRedirect: true (no effect on the static export;
   SWA handles trailing slashes itself).
2. .env.local DEV_API_PROXY_TARGET still pointed at the pre-EU-cutover
   autopilotmonitor-api (dead since 07-18) -> now -api-eu; proxy verified
   healthy (v1.5.1027).

Progress portal: header icon, icon circle, "Change device" link and
"Check Status" button on brand green; the blue activity indicators
(downloading/installing) stay blue — that is the portal-wide in-progress
semantic, not branding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): loading screens and remaining primary buttons on brand green

- All four loading spinners (AuthGate, ProtectedRoute, CalculatingCard,
  UnmappedSoftwareTab) green instead of blue
- Blue-indigo gradient loading/error backgrounds (AuthGate overlay,
  ProtectedRoute states, error page, CalculatingCard, SectionPlatformUsage)
  replaced with the neutral lp background (theme-aware in the portal)
- 19 non-contiguous primary buttons (bg-blue-600 text-white ...
  hover:bg-blue-700) the first sweep missed -> green, incl. ProtectedRoute
  retry/back buttons, error page, audit, diagnosis, session table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): last blue interactive states green; monitoring headers wrap on mobile

- Time-range toggles (fleet health, software, usage, dashboard, platform
  usage), software lens tabs, MCP-usage segmented controls, SLA report
  button, gather/analyze rule save buttons, and the page-header icons on
  fleet health / geographic performance -> brand green. Zero remaining
  bg-blue-600/indigo-600 interactive patterns.
- Monitoring page headers (fleet health, software, SLA, usage metrics,
  geographic performance, audit): title row gets flex-wrap + gap-y-3 and
  the control group (tenant scope + range buttons) wraps as a compact
  second line on narrow screens instead of pushing off the right edge;
  range buttons slightly compacted (px-3 py-1.5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map dev crash — per-mount container key for Leaflet

"Map container is already initialized": dev-mode remounts (StrictMode
re-runs, Fast Refresh over the dynamic() wrapper) hand Leaflet the same
container div twice and it refuses. A per-mount key on MapContainer
forces a virgin div for every (re)mount; MapCleanup keeps handling
instance teardown. No production behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): form control accents (radios/checkboxes) green portal-wide

Checked-state colors for radios and checkboxes (indigo/blue/amber/teal/
sky variants, 19 controls incl. the diagnostics destination radios and
subfolder checkboxes) unified on green, plus the diagnostics "Add" button.
Deliberately kept: red danger-confirmation checkboxes and the purple
global-admin control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): pin native form-control accent-color to brand green

Root cause of the "red" radios/checkboxes: there is no @tailwindcss/forms
plugin, so text-* utilities never styled native controls — their check
color silently followed the user's OS accent color. A global accent-color
rule (green-600, green-500 in dark) pins all radios/checkboxes to the
brand; the danger-red and global-admin-purple controls override via
accent-* utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): progress portal icon inherits item color again

Permanent green stood out against the gray siblings — the sidebar icon
now follows the item state like all others: gray at rest, green when
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): geo map root fix (react-leaflet 5) + readable dark muted text

- Root cause of "Map container is already initialized": Next 15 app
  router runs its bundled React 19 regardless of the installed react@18,
  and react-leaflet 4.x is incompatible with React 19 StrictMode
  double-invoked effects. Upgraded react-leaflet 4.2.1 -> 5.0.0
  (@react-leaflet/core 3) and removed our now-counterproductive
  workarounds (MapCleanup manual remove, per-mount container key) — v5
  owns the map lifecycle including unmount teardown. Verified via a
  throwaway repro page: map renders, remount toggle survives, console
  clean (repro page deleted).
- Dark mode: .dark .text-gray-400/500 lightened (#64748b -> #7b8ba3 /
  #8896ad) — notification timestamps and other muted text were nearly
  invisible on raised dark surfaces (unread bg, hover rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): subtler dark hover surfaces — hovered rows stay readable

.dark hover:bg-gray-50/100 dropped from #334155 to #283548/#2e3c52 (one
step above the card surface): the bright hover wash made notification
timestamps and other muted text illegible on highlighted rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): GA/tenant notification rows dark-aware — opacity variants escaped the overrides

The global-admin and tenant notification rows use bg-purple-50/30 and
bg-blue-50/30 (+ /50 hovers) — opacity-variant class tokens the global
.dark .bg-*-50 overrides never match, leaving light rows with light text
on dark mode. Explicit dark: variants (purple/blue-900 at low opacity)
fix the readability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): notification row hover clearly distinguishable in dark mode

GA (purple) and tenant (blue) rows: dark hover raised from /30 to /45
opacity so the highlighted row visibly differs from its resting tint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): one SegmentedControl for all range/group toggles

New shared components/SegmentedControl.tsx (the geo-style connected
pills on a gray track) replaces the three mixed patterns: loose button
trios on fleet health and software, the months <select> on SLA, and
geo's two inline toggles (time range + group-by) now all render the
same control with shared 7/30/90-day labels. Dark mode: active pill
uses arbitrary-value colors because the global .dark .bg-white override
would blend it into the track.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): MCP usage sections use the shared SegmentedControl too

The last remaining variant (joined bordered buttons with raw 7d/30d/90d
labels in admin metrics + reporting MCP usage) replaced by the shared
control with the unified "7/30/90 Days" labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clickable notification deep links in the bell dropdown

GA notifications now carry hrefs (session/diag-files report -> report
modal, preview signup -> tenant management filtered), GA rows render the
View link, and every bell row with an href navigates on row click and
closes the dropdown. Target sections accept ?reportId= / ?tenantId= via
the proven Suspense+useSearchParams pattern from DeviceBlockSection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(web): npm overrides pin react-leaflet peer deps to installed react

react-leaflet 5 peer-depends on react 19; the app router runs Next's
bundled React 19 at runtime while the installed package stays react 18.
Without the overrides a plain npm ci (SWA/Oryx build) fails on the peer
conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:21:18 +02:00
Oliver Kieselbach 9c50d98792 fix(web): sign-in landed back on www — HostRoutingGuard bounced before MSAL settled
Two defects of the same shape in the portal→www direction:
- Deterministic: a signed-in user opening portal root was always bounced to
  www (guard effect fires at hydration, before MSAL restores the session, so
  AuthGate never got to route "/" to /dashboard).
- Race: MSAL's module-level init strips the #code hash before hydration, so
  the auth-response exception saw nothing and the bounce destroyed the
  in-flight token redemption — the user ended up unauthenticated on www.

Portal→www now waits for settled, unauthenticated auth state; www→portal
stays auth-free. Decision extracted as pure decideHostBounce with the safety
rules pinned in hostRouting.guard.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:18:12 +02:00
Oliver Kieselbach 20b43e5349 feat(agent): gather debug log — phase transitions + per-match regex traces
- New "phase" stage: OnPhaseChanged writes a transition marker line
  (phase change: Old -> New) so rule-level lines read against the current
  phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
  and capture groups (<=100 chars, same selection as event data), capped at
  10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.

Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 20:07:52 +02:00
Oliver Kieselbach d0d1d28371 perf(backend): column projection for unmatched-software scan
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.

Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:22:07 +02:00
Oliver Kieselbach 5191751ed2 perf(web): lazy admin context + instant shell for software mapping
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.

/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.

Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:21:51 +02:00
Oliver Kieselbach 907118649b added playwright mcp 2026-07-29 16:00:17 +02:00
Oliver Kieselbach 26ecbf33d6 feat(web): instant-shell loading UX — table skeletons + route loading fallbacks
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.

- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
  loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
  table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
  auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
  triggered the MSAL redirect), usage-metrics, fleet-health, sla and
  admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
  pages) left untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:17:48 +02:00
Oliver Kieselbach adb103fc9e fix(web): always render the diagnostics opt-in in the report modal
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:11:04 +02:00
Oliver Kieselbach 26c8b80efa updated commands 2026-07-29 15:00:17 +02:00
Oliver Kieselbach 89c5c7da03 feat: richer, self-describing session report content
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:

- session.csv: append the 15 newer Sessions fields that had drifted out
  of the export (failureSource, reconcileReason, adminMarkedAction,
  validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
  connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
  failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
  eventStreamActive in the request and report-metadata.json; the modal
  warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
  failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
  rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
  pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
  attachments status in metadata instead of silently dropping invalid
  base64, SanitizeZipEntryName strips path components from customer-
  supplied file names (both report types)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:13:33 +02:00
Oliver Kieselbach 48308df4ff feat: opt-in preservation of session diagnostics archive with session reports
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.

- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
  report), reuses the Hosted/CustomerSas read paths, re-verifies the
  hosted tenant prefix via ClassifyBlobName, honours the admin size cap
  and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
  (written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:53:12 +02:00
Oliver Kieselbach cefa57fde5 feat(web): track negotiated SignalR transport in telemetry
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:25:05 +02:00
Oliver Kieselbach 946d503be0 feat(mcp): emit RFC 9207 iss parameter in OAuth authorization response
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 10:51:41 +02:00
Oliver Kieselbach f744ad8406 fix code review findings 2026-07-29 10:30:47 +02:00
Oliver Kieselbach 5e9876bc5f fix: hard page reloads on family routes — RSC .txt payloads were wildcard-rewritten
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).

Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:48:08 +02:00
Oliver Kieselbach ffe88a47c6 fix: /sla dead-ended on www — it is a portal page, not a public one
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".

Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.

Web tsc + 603/603, backend build clean + SLA suite 38/38.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:39:56 +02:00
Oliver Kieselbach 1f22b65d57 fix: /apps, /admin/backups, /admin/customs-archive list pages shadowed by detail rewrites
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:28:50 +02:00
Oliver Kieselbach 03afbf6ac6 fix: double sign-in on www→portal handover — silent SSO for app-triggered logins
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
  HostRoutingGuard's replace, signing the user in on the wrong origin.
  ProtectedRoute now stands down on the public host and lets the guard move
  the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
  including the automatic portal-side re-auth where Entra's session cookie
  would complete silently. login({auto:true}) (ProtectedRoute) drops the
  prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
  keep the picker.

Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:26:23 +02:00
Oliver Kieselbach 36172b8cc5 fix: www↔portal redirect loop — MSAL auth is per-origin, guards must not fight it
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.

Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
  response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
  no path — a bounce destroys the sign-in), plus a sessionStorage bounce
  counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
  return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
  window.location.href navigation (getPortalLoginUrl pattern) instead of
  router.push + guard cascade.

tsc clean, vitest 603/603, export build 87 pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:16:32 +02:00
Oliver Kieselbach 8849a68b1c fix: og/twitter/apple-icon as static PNGs — extension-less exports served as octet-stream
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:06:01 +02:00
Oliver Kieselbach cc195990ff fix: SWA deploy — drop invalid is_static_export input, dedupe trailing-slash routes
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:52:28 +02:00
Oliver Kieselbach 18b28260a7 feat: portal static export — eliminate the SWA SSR runtime (cold-start root fix)
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.

- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
  /diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
  /admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
  in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
  consolidated. Suspense boundaries added everywhere useSearchParams drives
  page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
  the *NavSections registries, dynamicParams=false, client bodies extracted to
  SectionClient) — 37 pages; server redirect() index pages became
  ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
  webhook notifications cannot be edited): SWA wildcard rewrites serve the
  family page and the globally mounted LegacyPathRedirect rewrites to the
  canonical query URL, preserving query + #event hashes (mapping pinned by
  lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
  lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
  BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
  redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
  override, all security headers incl. literalized prod CSP (no unsafe-eval),
  Content-Type routes for the extension-less og images. Guarded by the new
  utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
  first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
  (query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.

Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:44:32 +02:00
Oliver Kieselbach 6cbd507b4f fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.

- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
  (transport-derived, unspoofable; IPv4-mapped addresses normalized).
  Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
  X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
  (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
  fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
  Used by GetBootstrapScriptFunction (traffic arrives via
  go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 00:01:31 +02:00
Oliver Kieselbach 26bcab9054 feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.

- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
  hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
  pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
  invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
  expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
  raw string; golden test pins byte parity with the TS template
  (TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
  bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
  unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
  /api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
  legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
  removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
  section, index/log entries.

Backend tests 3611/3611, web vitest 651/651, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:42:56 +02:00
Oliver Kieselbach 10308664dd small UI fix in session list entry for started 2026-07-28 17:47:33 +02:00
Oliver Kieselbach 21b8e054df docs: correct portal-prefetch root cause (h2, server-side queue) + measured outcome
The HTTP/1.1 / six-connection-cap claim was wrong — it came from the
Schannel curl build that cannot speak HTTP/2. ALPN (openssl s_client)
and .NET HttpClient confirm the portal negotiates h2; the queue is
server-side in the SWA SSR runtime. Doc, index, log and the
GlobalSidebar comment now describe the real mechanism.

Adds the 2026-07-28 outcome section: Standard plan changed nothing
(stratified p50s match, Mann-Whitney p=0.81); prefetch={false} removed
the freezes (worst totalDuration 225s -> 4.9s) and also removed an
accidental keep-alive (~150ms added to cold navigations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:52:19 +02:00
Oliver Kieselbach 249c20e15a feat: mark backfilled pre-agent events as history in timeline + agent summary event
Customer feedback: backfilled modern_deployment_error events (pre-agent
Autopilot event-log errors replayed at agent start) rendered as plain
Error rows with one identical emission timestamp — indistinguishable
from a live failure.

Web (EventTimeline.tsx, effective for already-stored events):
- "⟲ Backfilled" badge (slate, explanatory tooltip) on data.backfilled
- row time now shows the original event-log timeCreated with a
  "(reported …)" suffix; metadata block gains a "Recorded" line;
  raw mode gets a compact ⟲ marker
- severity deliberately NOT downgraded — unresolved pre-agent errors
  must stay visible in the Error filter

Agent (ModernDeploymentTracker):
- backfill scan buffers the capped record set, emits an Info summary
  event first (modern_deployment_log, backfillSummary: true +
  replayedCount/droppedOverCap/lookbackMinutes) announcing the block,
  then replays oldest-first so timeline sequence follows event-log
  chronology (cap semantics unchanged: newest 25 win)

No new EventType, no ConfigVersion bump. Tests: summary emission x2,
tracker subset 12/12; tsc + vitest 651/651 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 16:10:25 +02:00
Oliver Kieselbach 4ebd60fb1e feat: EnableGatherRuleDebugLog — per-tenant gather-rule debug trace (ConfigVersion 36)
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.

- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
  field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
  failures swallowed, optional console echo), GatherRuleContext.DebugLog
  (null-safe no-op), trace points in executor + collectors; never writes
  under _scopeLock (reasons captured inside, written after release);
  merger bool->path projection with --gather-debug-log CLI override
  tolerance; deliberately NOT in the offline-cache strip lists;
  --run-gather-rules always traces; zero-rules delivery leaves a one-line
  file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
  InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
  report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
  lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:59:30 +02:00
Oliver Kieselbach 22093d0824 feat(agent): diagnostics upload at WhiteGlove Part-1 seal (preprov, V1 parity)
Upload the diagnostics package as a pre-provisioning intermediate when
Part 1 seals (suffix "preprov"). No new config field: the existing mode
semantics carry the desired behaviour — Always uploads at the seal,
OnFailure skips (the seal terminates as Succeeded; a Part-1 failure goes
through the regular Failed terminal path, which already uploads), Off
stays off. The upload runs before the whiteglove_part1_complete emit so
diagnostics_* events keep a lower sequence than the Part-1/Part-2
boundary marker. Best-effort: a fast reseal-reboot just loses the
intermediate package. Backend needs nothing — ingest already stamps
diagnostics_uploaded independent of terminal state (proven live by the
on-demand collection path).

Also make the on-demand path honest: CreateAndUploadAsync now takes
bool? enrollmentSucceeded — null means "no outcome yet" (mid-session),
so the OnFailure gate only skips a KNOWN success and sessioninfo.txt
records "In Progress" instead of the former enrollmentSucceeded:false
workaround that stamped in-flight packages "Failed".

10 new tests (WG seal mode matrix, preprov suffix, event ordering vs the
Part-1 marker, unchanged Part-1 exit contract; nullable-outcome gate
matrix via HTTP-free sentinel, sessioninfo labels). Agent suite
2112/2112 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:54:14 +02:00
Oliver Kieselbach dff1532be5 feat: project ConnectionType (WiFi/Ethernet) onto SessionsIndex
The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.

- Ingest: TryExtractConnectionType — last network_interface_info emission of
  the batch wins (a device switching media mid-enrollment is relabeled);
  only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
  mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
  MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
  client-side backstop on the non-OData paths, fields=connectionType
  projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
  fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
  filter (intended, same semantics as RebootCount); the deviceProperties
  scan path still covers historical data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:25:17 +02:00
Oliver Kieselbach 8608ee6272 feat(agent): netsh WiFi fallback labels for all enrollment-geography languages
Derived the language set from actual enrollment geography
(get_geographic_metrics, 365d, 10k sessions across ~50 countries) and
documented the country->language mapping on the key arrays. Newly
covered: Finnish, Hungarian, Lithuanian, Latvian, Romanian, Croatian/
Bosnian, Slovak, Indonesian/Malay, Vietnamese, Thai, Arabic, Bulgarian
radio-type variant and Swedish "Radiotyp". New fi/id parser tests; the
unknown-language tests now use Greek (outside the geography set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:18:34 +02:00
Oliver Kieselbach f762b932a2 feat(agent): CJK + Russian labels in netsh WiFi fallback parser
Extend the localized label sets with the remaining languages seen in
enrollments: Russian, Japanese, Chinese (Simplified + Traditional,
incl. the 信道/通道 channel variants) and Korean (incl. the two radio-
type spellings). SSID stays untranslated everywhere. New zh-CN and ko
parser tests; suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:14:07 +02:00
Oliver Kieselbach 173bfc40a9 feat(agent): multi-language label support in netsh WiFi fallback
The netsh fallback previously matched English labels only, silently
dropping fields on localized OS images. The parser now matches the
label variants of the languages commonly seen in enrollments
(EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE
spellings) and is split into a pure Parse(output) function covered by
unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision,
unknown-language degradation - SSID itself is untranslated in all
locales). Unknown labels still fall through harmlessly; the native
WLAN API remains the primary path.

V2.Core suite 2095/2095 green; fallback live-verified against netsh
on dev hardware (identical values to the API path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:08:46 +02:00
Oliver Kieselbach 609d9c47af fix(agent): netsh fallback + step diagnostics for WiFi info collection
Field regression on 2.0.1383 (session f7c56065, HP ProBook 450 G7,
build 26200.8037): the native WLAN API path returned nothing in the
OOBE/SYSTEM context and the null path was silent, so wifi_signal_info
disappeared entirely - while netsh-based 2.0.1380/1382 sessions on the
same build kept reporting SSID/signal the same morning. Suspected (not
yet proven) cause: Wi-Fi location gating of WlanQueryInterface on
24H2+; inbox netsh is unaffected.

- WifiInfoReader: new TryGetCurrentConnection overload reporting WHY
  the read yielded nothing (native rc per step, interface state);
  callers log it at Debug so the agent log explains missing WiFi info.
- NetshWifiFallback (new): parses netsh wlan show interfaces ONLY when
  the WLAN API yields nothing on a WiFi-connected device; SystemPaths.
  Netsh restored for it.
- WifiConnectionInfo.SignalPercent is now int? (netsh fallback cannot
  always parse localized "Signal"); event payload emits the field only
  when present - same shape as before.

V2.Core suite 2085/2085 green; interactive positive path re-verified
(SSID/94%/802.11ax/ch116, empty diagnostics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:55:46 +02:00
Oliver Kieselbach 5496d575c1 style: two-line Started column (date over time) in session list
Narrows the column so more columns fit on one page; matches the
two-line style of Device/Model/Status cells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:37:01 +02:00
Oliver Kieselbach 1c3d29f30b refactor(agent): read WiFi info via native WLAN API instead of netsh parsing
netsh wlan show interfaces output is localized: "Radio type"/"Channel"
(and on some languages even "Signal") never matched on non-English
images, silently dropping fields from wifi_signal_info and network
change snapshots. It also spawned a child process per collection.

Replace both netsh call sites with wlanapi.dll P/Invoke
(WlanQueryInterface: current_connection + channel_number):

- New WlanNativeMethods (raw interop) + WifiInfoReader (fail-soft,
  prefers the active NIC's interface GUID, falls back to the first
  connected WLAN interface; returns null on machines without WLAN).
- DeviceInfoCollector.CollectWiFiSignalInfo and
  NetworkChangeDetector.CollectWiFiInfo now use the reader; RadioType
  strings stay netsh-compatible ("802.11ax" ...), event fields
  (wifiSsid/wifiSignalPercent/wifiRadioType/wifiChannel) unchanged.
- SystemPaths.Netsh removed (no remaining references).

V2.Core suite 2085/2085 green incl. new WifiInfoReader tests; struct
marshaling verified against native sizeof (604/68/36/532).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:20:12 +02:00
Oliver Kieselbach bb4accfe96 feat: API Latency as optional column in dashboard session list
Off by default; sortable via avgApiLatencyMs. Data comes from the
SessionsIndex mirror the list already reads — no extra query or table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:11:20 +02:00
Oliver Kieselbach 0ec481b479 feat(agent): outcome-calibrated agent_late_start severity and phrasing
Fleet data (2026-07-28, agent 2.0.1380) showed the late-start detector was
outcome-blind: tenant 659c3a90 collected 12 Warnings with the post-mortem/
hung-script note on enrollments that succeeded — the bootstrap merely ran
late in the ESP queue and the agent observed the healthy final minutes live.

New pure helper ObservationCoverage.DescribeLateStart:
- Succeeded -> Info with a tail-only-coverage note (IME-replay hint,
  bootstrap-ordering review if recurring); no alarm framing.
- Failed/TimedOut -> Warning with the existing post-mortem/hung-script note.
- oobeStateAtAgentStart refines the message only: "completed" = pure
  post-mortem, "in_progress" = live tail observed; not_started/unavailable
  add no claim (value flips to not_started after mid-OOBE reboots and
  "completed" is routine on post-desktop restarts).

The coverage gate (10 min boot-to-start / 5 min uptime) is unchanged, and
the OOBE state is deliberately NOT a gate: the canonical hung-script
failure (f148976f) terminates on the still-shown ESP screen, i.e. with
OOBE reading in_progress.

8 new tests (outcome x oobe-state matrix); V2.Core suite 2067 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:53:17 +02:00
Oliver Kieselbach 3ec716a3ec feat: expose API latency to MCP field projection and tool docs
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
  apiRequestCount so search_sessions lean sweeps (e.g.
  fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
  latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
  hint; get_geographic_metrics doc names avgApiLatencyMs /
  apiLatencyVsGlobalPct as the backend-region distance metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:28:01 +02:00
Oliver Kieselbach b0bb7233e4 chore: drop Outbound IP row from Connectivity section
The outbound_ip trace event stays available for analysis (timeline,
MCP); only the UI row is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:20:35 +02:00
Oliver Kieselbach 6758dc59e4 refactor: move API latency into Device Details "Connectivity" section
The extra grid cell in the Session Info card forced a fourth row once
Device Validation was present. The latency reading is a "when needed"
detail, so it moves into the collapsed Device Details card as a new
Connectivity section instead — together with values that were collected
but never displayed anywhere: the estimated internet bandwidth
(network_bandwidth_estimate incl. DO-throttle hint), total telemetry
upload volume (agent_metrics_snapshot), and the outbound IP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:12:08 +02:00
Oliver Kieselbach ffd70e0383 feat: geographic API-latency view (per-location weighted avg by geo)
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):

- ComputeGeographicMetrics: per-location and global request-weighted
  latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
  serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
  otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
  table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
  absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
  counters when present (fallback: mean of interval averages)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:58:53 +02:00
Oliver Kieselbach c18154d76d feat: persist per-session agent-to-backend API latency (AvgApiLatencyMs)
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.

- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
  (total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
  ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
  last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
  superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:44:46 +02:00
Oliver Kieselbach f524473305 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-28 09:54:07 +02:00
Oliver Kieselbach 8fe420b902 updated commands 2026-07-28 09:53:57 +02:00
dependabot[bot] 2e8c2cdb2f chore(deps): bump @modelcontextprotocol/sdk (#110)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk).


Updates `@modelcontextprotocol/sdk` from 1.29.0 to 1.30.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.29.0...1.30.0)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:44:13 +02:00
dependabot[bot] e04b75dd53 chore(deps): bump the web group (#111)
Bumps the web group in /src/Web/autopilot-monitor-web with 3 updates: [next](https://github.com/vercel/next.js), [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next).


Updates `next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.21...v15.5.22)

Updates `@next/bundle-analyzer` from 16.2.11 to 16.2.12
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.12/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 15.5.21 to 15.5.22
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.22/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.22
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 09:41:30 +02:00
Oliver Kieselbach d55b0ff864 test(backend): pin the cross-tenant redaction on metrics/ime-versions
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.

There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.

10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:

  - non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
    VALUES absent anywhere in the payload (a model rename must not let the
    identifier survive under another key)
  - non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
  - non-global: sessionCount survives - the counts are platform-wide by
    decision, pinned so it cannot drift either way unnoticed
  - global (Admin or Reader): full entry including both identifiers
  - both scopes: array shape, empty archive, row order preserved

Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:33:56 +02:00
Oliver Kieselbach 123ba0e55e fix(web): fleet-health crash in GA all-tenants scope - repeatDevices absent
The worker serializer omits null fields (WhenWritingNull), so the
aggregated global/metrics/device-journeys response carries no
repeatDevices at all. The strict `=== null` check missed undefined and
fell through to repeatDevices.length, crashing the page. Verified
against the deployed chunk: crash column 11464 is the `0===o.length`
read behind the `null===o` guard.

Loose `== null` check + optional DTO field; absent and null both render
the "select a tenant" hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:28:14 +02:00
Oliver Kieselbach c8175ff7b1 fix(mcp): drop npm from the runtime image, bump tar to 7.5.22
Defender for Cloud reports CVEs against the mcp container image that the
repo scanners cannot see. The subassessment evidence paths split them in two:

  /usr/local/lib/node_modules/npm/**  - the npm 10.9.8 that node:22-slim
  bundles, vendoring @sigstore/core 2.0.0, brace-expansion 2.0.2, tar 7.5.11
  (critical), sigstore, picomatch, ip-address, plus npm itself (high, no fix
  available). Present in all 20 images including the current one. Dependabot
  reads package-lock.json, so none of this is visible to it.

  /app/node_modules/**  - body-parser 2.2.2, fast-uri 3.1.2, hono, protobufjs,
  tar 7.5.16, all confined to images from 2026-07-21 and earlier and already
  fixed by 7df208fa/c3e9621a. Those findings age out with the old ACR tags.

npm is build-time only here: CMD is `node dist/index.js`, the HEALTHCHECK is
`node -e`, the container app spec overrides no command and probes over HTTP,
and src/ contains no child_process call. So remove npm rather than upgrade it
- npm 11.x still vendors @sigstore/core 3.1.0, itself inside the vulnerable
<= 3.2.0 range, and would clear nothing.

Separately, tar 7.5.20 -> 7.5.22 in our own tree (GHSA-r292-9mhp-454m, via
@huggingface/transformers -> onnxruntime-node). 7.5.22 satisfies the declared
^7.0.1, so no override is needed. fast-uri deliberately stays on 3.1.4: it is
patched against all four fast-uri advisories, and ajv 8.18.0 declares ^3.0.1,
so forcing 4.x would be an override against a major range.

npm audit --omit=dev: 0 vulnerabilities. tsc clean, 455 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:00:07 +02:00
Oliver Kieselbach 0efeb0f734 fix(backend): alert-webhook 500 on non-seekable body - bounded read replaces Length check
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:52:59 +02:00
Oliver Kieselbach 7b975d46b6 feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.

Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.

Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).

Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:39:22 +02:00
Oliver Kieselbach 7680bdff7b fix(web): disable RSC prefetch on nav links - portal freeze root cause
Navbar + GlobalSidebar render the full navigation on every page, so the
App Router's default viewport prefetch fired one ?_rsc= request per
visible link against the SWA runtime (~10.4k requests / 14 days). That
origin serves document, chunks and RSC payloads over HTTP/1.1 (6
connections per origin); when the runtime queued the prefetches (p95
896ms, tail up to 234s), they held every socket and the portal froze
until the queue drained. Measured on the 2026-07-27 17:25 incident:
/admin/presence?_rsc= at 151s while the backend API answered in <2.2s.

All 16 nav links now set prefetch={false}. Every target route is a
client component behind ProtectedRoute that fetches its own data after
mount, so the prefetched RSC payload carried no benefit.

New OKF doc docs/web/portal-navigation-prefetch.md records the full
evidence chain and where browser telemetry lives (separate App Insights
resource, not reachable via MCP query_backend_logs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:27:24 +02:00
Oliver Kieselbach 93094653a2 fix(web): tenant-scope seed races on usage-metrics/sla - own-tenant stats shown for override
A GA with a persisted tenant override opening /usage-metrics (or /sla) saw
their OWN tenant's stats until a manual refresh. useGlobalAdminScope seeded
the persisted selection in a post-mount effect, so the page effect first saw
effectiveTenantId = own tenant and fired an own-tenant fetch immediately
followed by the override fetch; with raw authenticatedFetch (no latest-wins
guard, unlike useAuthenticatedFetch since 82934a37) the last-RESOLVED
response won.

- useGlobalAdminScope: seed the GA selection render-time (same pattern as
  the delegated seed) so consumers never observe a transient own-tenant
  effectiveTenantId - the wrong fetch no longer starts at all.
- usage-metrics/sla pages: latest-wins sequence guard in fetchMetrics so a
  superseded request can neither write data/notifications nor clear the
  newer request's loading state (covers rapid selector switches).

Verified: tsc 0 errors, vitest 651/651.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:08:12 +02:00
Oliver Kieselbach ba7d14b070 fix(insights): post-deploy MCP findings - projection drop, aggregate starvation, duration artifacts
Three defects found by measuring production after the 1.5.1012 deploy:

- AppMetricsProjection dropped TerminalState, so the PR0 skip
  classification never reached metrics/app (totalSkipped stuck at 0).
  Column added; equivalence fixture now populates TerminalState/AppId/
  EspBlocking/AppIdCollision and asserts them (proven: test fails
  without the projection fix).
- BuildTimeAttributionAggregates excluded breakdowns on ANY quality
  flag; 903/1000 carry BlockingSetUnknown, starving fleet stats
  (Clean=12 vs 331 excluded on the largest tenant). New
  TimeAttributionFlagQuality.DurationCriticalFlags mask - only
  ClockSkew/PartialObservation/WGAnchors exclude; blocking-set flags
  keep their measured spans (positive-evidence semantics unchanged).
- HasMeasuredDuration gets an upper plausibility bound (6h = default
  AgentMaxLifetimeMinutes): 490 rows carried back-stamped completions
  (up to 117 days; 60175s app rows in a 2697s session) that dominated
  slowestApps with six-figure averages. Read-side, heals old + new rows.

Backend 3484/3484 green; solution build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:50:55 +02:00
Oliver Kieselbach 7f7330e9e5 feat(web): auto-recover from post-deploy stale-chunk failures (one reload, loop-guarded)
A tab still running the previous deployment's bundle requests lazily-loaded
chunks (next/dynamic charts, route chunks) whose content-hashed filenames no
longer exist after the SWA swap - the user is stuck on "Loading chart..." or a
dead navigation until a manual refresh. Standard remedy: reload once so the
fresh document references the new bundle.

- utils/chunkReloadRecovery.ts: chunk-error detection (webpack ChunkLoadError +
  Chromium/Firefox/Safari dynamic-import variants) and a sessionStorage guard -
  at most one auto-reload per tab per 5 minutes; without working storage no
  auto-reload at all, so a reload loop is structurally impossible. Emits a
  ChunkReloadRecovery App Insights event before reloading.
- components/ChunkReloadRecovery.tsx (root layout): catches rejected dynamic
  imports via unhandledrejection and failed /_next/ script/link tags via a
  capture-phase error listener (third-party scripts never trigger a reload).
- app/error.tsx: a chunk error reaching the boundary reloads once instead of
  showing the error card; inside the guard window it falls through to the card.

Verified: tsc clean, vitest 651/651 (8 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:47:34 +02:00
Oliver Kieselbach 82934a3719 fix(web): tenant-aware unrestricted badge + latest-wins fetch guard on rules pages
GA override on gather-rules called GET /api/global/config/{tenantId}, a route
that does not exist in the backend; the swallowed 404 left the unrestricted
badge showing the GA's HOME tenant's mode for every foreign tenant. Both paths
now read the member-readable config/{tenantId}/feature-flags (MemberRead has a
global-scope bypass and the route is RouteParam-scoped), with a reset-on-switch
so a failed fetch never carries the previous tenant's mode over. The dead
globalConfig.tenant entry is removed from the URL register.

useAuthenticatedFetch now enforces latest-wins: the scope hook seeds a
persisted foreign tenant only after mount, so an own-tenant fetch races the
override fetch and the last-RESOLVED response used to win, leaving the rules
list on the home tenant while the selector showed the override. Stale requests
no longer write data/error/loading; the spinner is owned by the last-started
non-silent request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:26:01 +02:00
Oliver Kieselbach 372f0c7c98 feat(web): source pill for non-Intune install rows + per-source row key
Customers who additionally deploy Microsoft 365 Apps as their own Win32 app saw two
rows in Install Progress (the IME view and the Office C2R lifecycle) with nothing
telling them apart, which read as a duplicate.

- New lib/installProgress.ts: buildInstallItems() extracted from the component so the
  event-folding rules are unit-testable; component is now render-only.
- InstallItem gains `source` (ime | office-c2r | realmjoin) and a source-scoped `key`.
  Rows were keyed by app name alone, so a Win32 app named exactly "Microsoft 365 Apps"
  (= OfficeInstallDetector.AppName) collided with the C2R row and overwrote its state.
- Pill only for non-default sources: "Click-to-Run" / "RealmJoin", neutral grey so it
  does not compete with the state badge, with a tooltip explaining what the row measures.
  Plain Intune app rows stay unlabelled.
- Drops the "RJ: " name prefix, which broke sorting and truncation.

Verified: tsc clean, vitest 643/643 (16 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 11:24:53 +02:00
Oliver Kieselbach 8057b44e48 fix(agent): serialize ESP-config collects so a later emission never carries an older snapshot
Codex review (P2): the ESP-config refresh triggers each run on their own
Task.Run (as do CollectAll's startup sweep and CollectAtEnrollmentStart),
so two concurrent collects could interleave read-before-emit - the LATER
emission then carried the OLDER (smaller) registry snapshot. The backend
uses the latest list-bearing esp_config_detected emission as positive
blocking evidence, so an out-of-order emission would silently shrink it.

CollectEspConfiguration now runs its read->emit sequence inside a
dedicated lock covering ALL callers; a later emission always carries an
equal-or-newer snapshot. The race predates the Q2 refresh triggers (they
only raised its frequency).

Deterministic concurrency test: 12 parallel refreshes against a probe
whose list grows per read - the emission sequence must be exactly 1..12.

Verified: Agent V2 Core 2059/2059.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:56 +02:00
Oliver Kieselbach 7842d121a0 fix(insights): codex review findings - WG pause clamping, late-event healing, stale buckets, date windows, banner count
All six review findings verified against code and real; five fixed here,
the agent-side race lands in the companion agent commit.

[P1] WhiteGlove pause counted as time: ClampToWindows clipped intervals
against the HULL of both WG windows, but the excluded pause lies inside
the hull - a reboot gap or app interval straddling the pause counted the
entire pause (a ~2-day "reboot" in a 30-minute enrollment was possible).
Seconds are now the per-window intersection sum (chronological endpoints
kept); an interval wholly inside the pause makes no claim and no longer
raises ClockSkewDropped. What-if savings are additionally capped at the
session's WallClockSeconds - endpoint deltas can still straddle the
pause, and no removal can save more than the enrollment took.

[P1] Late-arriving events never updated breakdowns: the terminal-seam
compute is one-shot and the sweep only healed missing/version-stale rows.
SessionTimeBreakdown now stores EventCountAtCompute (0 = pre-fix row,
recomputed once); the sweep recomputes on EventCount drift and then also
re-runs ResolveEspBlockingForSessionAsync (newly exposed on
IMetricsRepository) so late esp_config_detected re-emits and app batches
reach both the breakdown and the blocking evidence.

[P2] Stale aggregate buckets: the sweeps now reconcile every partition
they wrote - buckets that existed but were not regenerated (sessions
deleted, class left the window) are deleted via new targeted deletes;
rolling30 rows additionally get a 48h ComputedAt freshness filter at
read time, so a fully quiet tenant stops serving year-old numbers as
"last 30 days".

[P2] Date windows included an extra calendar day: both inclusive range
ends meant days=30 returned 31 keys and days=1 summed yesterday AND
today. InclusiveWindowStart(today, days) = today-(days-1) in both
response builders; the sweep's rolling30 source is filtered to the same
30 calendar days (its query window stays one day wider for backfill).

[P3] Device-history banner counted later sessions as "previous
enrollments" when viewing an older session: selectPriorSessions
(deviceHistoryPrior.ts) anchors on the session's own chain entry (live
sessions: new sessionStartedAt prop), sessionId tie-break mirroring the
server chain sort.

Verified: solution build clean, Backend 3480/3480 (+6: three WG-pause
golden fixtures, window-start facts, RK builder/delete consistency,
EventCountAtCompute roundtrip), Web tsc + vitest 633/633 (+6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:39 +02:00
Oliver Kieselbach 4bc2435ec5 fix(agent): audit Q1/Q2/Q5 source fixes - timing re-arm, esp_config re-emits, serial trim
Q1 (AppInstallTiming): a terminal->active transition (IME retry path,
e.g. Error->Downloading or Skipped->Downloading) re-arms CompletedAtUtc
so the final terminal payload covers first-start -> last-terminal instead
of freezing at the first terminal state. Plus a negative-duration guard
in AppInstallTiming.DurationSeconds (inverted endpoints -> null, field
omitted) - observed in production as durationSeconds=-2342 on a
Skipped-then-retried user-scope app (session 66f1c5e1).

Q2 (esp_config_detected): the ESP blocking lists are read too early -
device lists grow progressively, user-scope S-<SID> lists appear only
after sign-in (88 of 89 prod sessions had an empty user list at last
emission). DeviceInfoHost now re-collects the ESP configuration as
phase-driven one-shots: apps sub-phase declaration (AppsDevice/AppsUser
phase_transition = first app activity) and AccountSetup detection.
The StartupEventGate keeps unchanged payloads silent (fingerprint covers
list contents), so only grown lists actually re-emit. F1 EspBlocking
resolution (latest list-bearing emission) benefits without backend change.

Q5 (HardwareInfo): WMI values (serial, manufacturer, model) trimmed at
the source via NormalizeWmiValue; whitespace-only collapses to the
"Unknown" sentinel the retry logic already treats as unresolved.

Attach-window investigation (11a3b67a): NO agent fix - hypothesis
disproved against two real agent logs (diag bundles db5ce007/2.0.1337,
4a9e9273/2.0.1379). "completed without started" first apps were already
installed (WinGet detection-only, zero install lines in AppWorkload.log);
pre-attach replay provably works (pattern-match log). PR0's
HasMeasuredDuration already handles the class server-side.
Duplicate app_install_completed (66f1c5e1): mechanism narrowed to an
in-process re-read of exactly one AppWorkload rotation window (<24h-old
lines, historic suppression not applicable) - documented as follow-up,
no fix without a bundle-bearing repro per lessons.md.

Verified: solution build clean, Agent V2 Core 2058/2058 (14 new tests),
DecisionCore 493/493. No ConfigVersion bump, no new event types,
decision rail untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:59:20 +02:00
Oliver Kieselbach 1bf37f6761 feat(insights): F3 regression radar - Wilson-gated rule hit-rate drift detection (PR6)
Analyze rules only (gather stats stay per-batch-deduped until Q6 is
fixed - never silently included). Per (tenant, rule) the radar compares
the trailing 7-day hit rate (FireCount/SessionsEvaluated from the daily
RuleStats rows) against the prior 28-day baseline and fires only when
the window has >=5 hit sessions and >=20 evaluated sessions, the rate
is >=2x baseline AND the Wilson 95% intervals are disjoint
(MetricsMath.WilsonInterval/RateIncreaseSeparated, hand-computed test
vectors). Suppressed for deleted rules, rules inside the CreatedAt
grace period (<35d), rules edited inside the window, empty baselines,
and the RuleRegressionRadarDisabled app-setting kill switch.

Alert episodes live as ruleregression|{ruleId} rows in the notification
tracker (third keyspace): AddEntity-409 dedup = one bell + one
RuleFrequencyRegression ops event (category Tenant, Warning, full
numbers, dual-registered in the web OPS_EVENT_TYPES) per episode;
numbers refresh while active without moving FirstNotifiedAt; episodes
re-arm under 1.5x baseline, on fire-stop, or via the tracker's 30d
retention prune (spec-intended reminder). On-fire dimension correlation
(osBuild/model/agentVersion/imeVersion; hit set from a RuleResults
range scan intersected with the window sessions) reports both shares
with the "correlated, not necessarily causal" wording contract or
honestly "no clear dimension concentration". Radar runs in the daily
maintenance pass after the rule-stats aggregation, anchored on
yesterday (whole days only).

Surfaces: regressions[] in the rule-stats response (MCP get_rule_stats
inherits it), tenant bell deep-linking to the rule card, and the
analyze-rules page gains a densified 30-day fire sparkline plus a
regression badge while an episode is active. OKF doc
backend/rule-regression-radar.md + index/log.

Backend 3474/3474 (27 new: Wilson vectors, every suppression branch,
window boundary dates, re-arm matrix, dimension gates, tracker
round-trips incl. tri-states, keyspace dedup/prune, wording pins),
web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 09:10:52 +02:00
Oliver Kieselbach 88da317bf4 feat(insights): F2 surfaces - device-history banner, fleet FTR panel, API routes, MCP tool (PR5)
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).

Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.

Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:38:17 +02:00
Oliver Kieselbach 1e6de47433 feat(insights): F2 journey grouping + DeviceHistories chains + daily FTR aggregates (PR4)
Device-key normalization (trim+casefold, junk-serial exclusion incl. the
agent's "Unknown" WMI sentinel), pure journey grouping (terminal set
Succeeded/Failed/Incomplete; journey ends at first success; >30d gap from
the previous attempt's CompletedAt starts a new journey; WG Pending stays
an open session, never an attempt), and the DeviceHistories table
(PK tenant, RK encoded normalized serial): terminal-only session-ref
chains capped at 20 with derived journey counts, written inline at the
session-terminal seam (all three terminal statuses, DeletionState-guarded)
and healed by a rolling 30d maintenance sweep. The sweep prunes chain refs
of deleted sessions tombstone-driven (SessionTombstones cover cascade AND
retention deletions), deletes emptied rows, and recomputes daily FTR
aggregates (DeviceJourneyAggregates, PK tenant/"global", RK date):
completed journeys, first-time-right count, attempt histogram and
junk-serial disclosure — additive counts, bucketed on the completing
success session's StartedAt date. Chain refs carry the authoritative
DurationSeconds verbatim (never CompletedAt-StartedAt) plus an
adminMarked flag. Both tables registered in Constants.All + offboarding
TenantPartitionTables; aggregates under the 180d retention block;
deliberately NO per-session deletion-manifest step for DeviceHistories.

Backend 3433/3433 (51 new tests: junk/normalization vectors, terminal
set, exact 30d gap boundaries, WG one-row-one-attempt, redeploy split,
deletion pruning, entity round-trips incl. row-key escaping, FTR
aggregation core), Agent 2044+493 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 08:14:13 +02:00
Oliver Kieselbach 6de21e821a feat(insights): F1 surfaces — session lane, fleet panel, API routes, MCP tool (PR3)
Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.

- Rolling range rows: the sweep now also writes "rolling30|{class}"
  aggregate rows — range statistics over the window's actual sessions. A
  median of per-day medians is not the range median, so the fleet panel
  reads these and is deliberately fixed to "last 30 days"; the daily rows
  stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
  GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
  param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
  a missing lane is a normal state, not an error), GET
  metrics/time-attribution (MemberRead) and GET
  global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
  both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
  proportional stacked bar of segment totals incl. the explicit
  unattributed remainder (bar always sums to the session's authoritative
  duration), ESP-blocking apps with critical-path occupancy, reboot badge
  (cross-cutting, never a bar slice), quality-flag chips; fetched
  fail-soft for terminal sessions only. Fleet Health gains the "Time
  attribution" section: per-class stacked medians gated at n>=20
  ("insufficient data (n=...)" below), flagged/missing exclusions
  disclosed, and a top-blocking-apps table with class-scoped rows and
  "up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
  per-app trend column is omitted — per-day app rows are too sparse under
  the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
  routing like get_app_install_metrics, registered for all roles) — the
  privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
  segment mapping, positive-evidence blocking, Q7 reboot spans,
  persistence + surfaces) registered in index.md + log.md.

Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:33:16 +02:00
Oliver Kieselbach 55c898bb9c feat(insights): F1 breakdown persistence + daily attribution aggregates (PR2)
Insights expansion PR2 (tasks/insights-expansion-spec.md §F1 "Data & compute
changes"):

- SessionTimeBreakdowns table (PK=TenantId, RK=SessionId): span/reboot/
  blocking-app JSON columns, QualityFlags, AttributionVersion,
  EspAppsOccupancySeconds kept tri-state (absent column = unknown, never 0).
  Written once at the terminal single-writer seam (TableSessionRepository,
  next to the counter reconcile + EspBlocking resolution; Succeeded/Failed
  only — Incomplete deliberately stores no duration). Deleted with its
  session: new deletion-manifest step 16 (PkRkExact; tombstone moves to 19)
  plus tenant-offboarding partition wipe — no separate age sweep.
- Self-healing 30d maintenance sweep instead of a one-off backfill (rides
  the existing 2h tick + manual parity): backfills pre-deploy sessions,
  heals inline-compute misses, folds late-terminating sessions into their
  StartedAt-date aggregates, and enforces AttributionVersion purity — a
  stale-version breakdown whose events aged out counts as missing rather
  than mixing algorithm semantics (truthfulness rule 8). Skips sessions
  with a deletion cascade in flight.
- TimeAttributionAggregates table (PK=TenantId or "global",
  RK="{date}|{class}"): per enrollment class (whiteglove /
  device_preparation / self_deploying / user_driven — never mixed),
  median/p75/p90 per segment over the fixed six-segment stack (incl.
  unattributed; absent segment = honest 0), clean/flagged/missing counts
  disclosed (rule 7), rows written below the n>=20 UI gate on purpose
  (the UI needs the n — rule 4). 180d retention alongside UsageMetrics.
- What-if bound: WhatIfSavingSeconds implements the spec formula
  max(0, cpEnd - cpEndWithoutX) including the idle gap before X (the ESP
  waits for X to start, too); sole-interval fallback = own start. Per-app
  aggregate rows gate at >=5 sessions, top 20 by median interval, carry
  median/p75 of interval and saving ("up to" wording is the consumer
  contract).

Backend 3381/3381 (13 new tests: entity round-trips incl. tri-state,
aggregation bucketing/disclosure/gates, what-if vectors, class precedence,
manifest step-count pins 17/19), solution build clean, web untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:03:14 +02:00
Oliver Kieselbach e637317bdb feat(insights): F1 schema + time attribution calculator (PR1)
Insights expansion PR1 (tasks/insights-expansion-spec.md §F1, audit §0.5):

- AppInstallSummary + AppId / EspBlocking (bool?, tri-state) / AppIdCollision:
  sentinel-gated columns + mapper + both projections. appId adopted from app
  events (first-seen wins); a second appId under the same display name flags
  the name-keyed row as a collision (audit Q3) — in-batch and cross-batch
  (sticky in the reconcile). Collision rows leave every per-app fleet
  aggregate (app metrics payload, apps list, app analytics) with a disclosed
  exclusion count; the fleet DO rollup and per-app session drill-down keep
  them.
- EspBlocking resolution at the session-terminal single-writer seam
  (TableSessionRepository.UpdateSessionStatusAsync, next to the counter
  reconcile — covers ingest, admin marks, maintenance sweep, rule engine):
  joins the session's app rows against the LATEST list-carrying
  esp_config_detected emission as a positive-evidence set (audit Q2) —
  listed => true, absent => stays unknown, never false. EspBlockingSets
  parser handles truncation via the uncapped espTracked*Count fields.
- TimeAttributionCalculator (pure, AttributionVersion 1) + SessionTimeBreakdown
  models: exact partition of the session's authoritative DurationSeconds into
  device_prep / esp_apps / identity_hello / user_esp / desktop_handoff spans
  plus an explicit unattributed remainder — invariant sum(spans)+unattributed
  == wall clock enforced in every test. WhiteGlove = two observation windows
  in ONE session (part1 anchored on whiteglove_part1_complete, pause never
  attributed). Per-app blocking intervals from EVENT timestamps (first start
  -> LAST terminal, audit Q1 retry freeze), occupancy = merged union clipped
  to esp_apps. Reboot spans from event gaps around lastBootUtc (audit Q7),
  deduped per boot. QualityFlags: ClockSkewDropped, PartialObservation,
  BlockingSetUnknown, BlockingSetTruncated, WhiteGloveAnchorsIncomplete.
  Persistence (SessionTimeBreakdowns) and surfaces follow in PR2/PR3.

Backend 3368/3368 (35 new tests: 19 calculator golden fixtures + 16
identity/blocking/aggregate-exclusion), solution build clean, web untouched
(new response fields are additive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 17:29:36 +02:00
Oliver Kieselbach 4eac7d042c fix(metrics): app duration/rate stats over real installs only — TerminalState column + out-of-order reconcile
Source-data audit (tasks/insights-expansion-spec.md §0.5) verified against
production: 17.9% of Succeeded AppInstallSummaries rows carry no duration —
78% are genuine skips (app_install_completed with payload state=Skipped,
"Update for X" WinGet policies not applicable) whose state the ingest
discarded, the rest are real installs whose start the agent never observed
(attach window). Averaging them as 0s understated avg install duration by
18% and distorted the slowest-apps ranking.

- AppInstallSummary.TerminalState (Installed/Skipped/Postponed/Error) from
  the closing event's payload state; sentinel-gated column + mapper +
  AppsDashboardProjection. Status stays "Succeeded" for compatibility.
- MetricsMath.IsSkipTerminalState/HasMeasuredDuration: durations computed
  over measured rows only; skips leave the failure/success rate (decision
  2026-07-26 — a skip is not an install attempt); per-app skipped/unmeasured
  counts + totals exposed; slowest ranking gates on measured installs.
  Applied consistently: app metrics payload, apps dashboard (list/analytics/
  time series/trend/versions/device models), SLA weekly/snapshot/breach.
- StoreAppInstallSummaryAsync: reconcile extracted to internal static
  ReconcileAppInstallSummaryWithExisting (test seam). Fixes Q4 (terminal
  batch before started batch left duration permanently unset) and hardens
  terminal status against late/replayed InProgress batches.
- Web: measured-only hints on app detail cards, skip suffix in apps list,
  fleet-health "measured installs" caption.

Backend 3333/3333 (20 new tests), web tsc clean, vitest 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:24:45 +02:00
Oliver Kieselbach c7ed3ec27d more safe SecureBootCertReader and section plan layout update 2026-07-25 12:48:27 +02:00
Oliver Kieselbach f311e8746b docs(trust): on-demand log collection in customer-facing claims + tracker dedup tests
The "Collect Logs" feature (b382e567) added a second opt-in path into hosted
diagnostics upload — the quick-config dialog on a session — and a new collection
trigger, neither of which the customer-facing pages described. The trust FAQ still
said hosted upload is enabled "in the settings UI", and the dialog itself named
hosted storage without stating that packages leave the tenant boundary, so the
"clearly marked disclosure" promise was not met on that path.

- CollectLogsButton: marked data-boundary disclosure in the quick-config dialog
  (leaves your Azure tenant, per-tenant isolation, blob-scoped write-only token,
  removal per Data Retention Days)
- privacy + about pages: both opt-in paths named; on-demand collection described
  (Admin/Operator of the own tenant, same content as the automatic package,
  delivered with the agent's next check-in, visible on the session timeline)
- docs repo counterpart in trust/security-faq.md (separate commit)

Distress-bell dedup: the TpmPssUnsupported one-shot bell had key-shape tests only.
New HardwareRejectionNotificationTrackerDedupTests pins the 409 dedup branch, the
fail-soft branch, the blank-input short-circuit, the disjoint hardware/tpmpss key
spaces, and the retention sweep — which also prunes tpmpss rows and thereby re-arms
that device's bell, now stated in the interface and implementation docs.

Backend tests 3313/3313, tsc clean, web vitest app/sessions 60/60.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:43:42 +02:00
Oliver Kieselbach e0b6ce83cf fix null warnings 2026-07-25 00:17:31 +02:00
Oliver Kieselbach 7f7b590db4 fix(web): mobile readability for rule cards + phase timeline overlap
Rule list cards (analyze rules, gather rules, IME log patterns) used a
single-line flex header where every badge was flex-shrink-0 and only the
title could shrink — on phone widths the title truncated to a few
letters. The header now wraps: on mobile the title drops onto its own
full-width row (order-last + w-full) under the badge row and may wrap;
on >=sm the layout is unchanged (title inline, truncated).

PhaseTimeline squeezed up to 8 flex-1 columns with 48px circles and
whitespace-nowrap labels into the viewport, so circles and label text
overlapped completely on mobile. The timeline now has a minimum width
of 80px per phase inside an overflow-x-auto container (swipe on phone,
no scrollbar on desktop) and labels wrap inside their own column
instead of overlaying neighbours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 00:04:04 +02:00
Oliver Kieselbach 4c2c447a2e fix(web): dark-mode slate/hover-tint overrides + mobile wrap for script executions
Dark mode: globals.css was missing the entire slate family (the
"Incomplete" status filter chip rendered white-on-white) and all light
hover tints outside gray/indigo/cyan (hover:bg-purple-50 in the tenant
suggestion dropdown flashed near-white under light text). Added slate
bg/text/border mappings mirroring gray, plus dark equivalents for every
hover:bg-*-50/100/200 class in use (blue, purple, red, amber, yellow,
green, orange, sky, teal, emerald, slate, gray-200/300).

Mobile: ScriptExecutions rows were single-line flex with flex-shrink-0
right clusters, so badges and status text overlapped/overflowed on
narrow screens. Header counters, cycle card headers and item rows now
flex-wrap with gap spacing instead of space-x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:46:03 +02:00
Oliver Kieselbach 8a794a1b52 fix(rules): cross-tenant GA rule edits no longer misroute to home tenant
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.

Fix: symmetric cross-tenant WRITE routes mirroring the global reads.

Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
  global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
  missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
  variants, same service calls (toggle-partial/built-in-state/full-edit
  semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
  write tier, so the delegated-read rescue never fires; only a platform
  admin may mutate another tenant's rules. No global CREATE route:
  create stays JWT-only and the portal hides Create in override.

Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
  route save/toggle/mark-failed/notify/delete through a
  *RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
  clear message instead of silently writing to the wrong tenant.

Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.

Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 23:21:37 +02:00
Oliver Kieselbach 6c53dc33e7 feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.

- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
  mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
  (tenant, serial); dedup via the existing rejection tracker table with a
  disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
  section; renders only when a device is affected (no permanent card)

No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:47:06 +02:00
Oliver Kieselbach 801891d57a layout update section plan comparison 2026-07-24 20:30:28 +02:00
Oliver Kieselbach 1f6efca95b fix(backend): stamp DiagnosticsBlobName from on-demand server_action_executed events
Session 8e4cc4ae: two "Collect Logs" round trips completed on the agent (both
ZIPs landed in hosted storage) but the portal never flipped to Download and
the button hung at "Collecting..." — the ServerActionDispatcher confirms an
on-demand upload only via server_action_executed(actionType=request_diagnostics,
blobName=...) and emits NO diagnostics_uploaded (only the terminal upload path
does), so UpdateSessionDiagnosticsBlobAsync never ran.

The ingest classification now treats such an executed event as the upload
confirmation (a real diagnostics_uploaded in the same batch still wins — it
carries destination). Because the executed event has no destination field,
InferDiagnosticsDestination derives "Hosted" from the backend-built
"{tenantId}/{filename}" blob-name shape (CustomerSas blobs are bare
filenames); without it the download route would look in the customer's
container for a hosted blob.

Backend-only — works with the already-deployed agent fleet, no agent release.
Backend suite 3274/3274.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:23:47 +02:00
Oliver Kieselbach 9ec8644be2 feat(settings): operator read-only settings view with real values
Operators previously saw editable settings forms bound to hardcoded defaults
whose Save always 403'd. Now they see the tenant's real configuration
read-only, with no save affordances at all.

Backend:
- GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because
  the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin
  see secrets); Operators/Viewers receive RedactedCopyForReader().
- POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting
  logs is a troubleshooting action; Viewer and read-only GlobalReader excluded).
- Tests: policy pins updated; new middleware tests (own-tenant Operator allowed
  + redacted, cross-tenant Operator 403); CanViewSecrets theory for
  Operator/Viewer.

Web:
- TenantConfigContext loads the full config for all settings users (403
  fallback to feature-flags minimal config for deploy-order safety) and
  exposes canEditConfig; saveConfiguration guards on it.
- New ReadOnlyFieldset wraps section content (native fieldset disable, values
  stay copyable); SaveResetBar is not rendered at all for read-only viewers.
  Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights
  toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data
  Management.
- Hidden entirely from Operators (in-page admin-only card + sidebar sub-item
  gate): Autopilot Validation, Access Management, Offboarding, Unrestricted
  Mode. GlobalReader sidebar parity kept via hasGlobalScope.

Tests: backend 3257/3257, web vitest 627/627, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:40:27 +02:00
Oliver Kieselbach 9acd10e1dc docs(okf): ServerActions & on-demand diagnostics concept doc
Covers the queue-on-session/deliver-on-ingest channel, the
TenantAdminOrOperator tier with in-function per-type re-gating, the portal
Collect Logs flow (quick-config orders rotate_config before
request_diagnostics), sequence-based progress matching, and the hosted-prefix
sweep in the offboarding cascade. Registered in index.md, logged in log.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:15:23 +02:00
Oliver Kieselbach b382e5678e feat(web): "Collect Logs" on-demand diagnostics collection on the session detail page
Queues a request_diagnostics ServerAction (delivered with the agent's next
ingest response) and tracks progress live: server_action_received flips the
button to "Collecting…", the DiagnosticsBlobName SignalR delta completes it
(Download Diagnostics appears), server_action_failed or a 3-min quiet timeout
surface as toasts.

- Button is ALWAYS rendered — disabled states carry tooltips (inactive
  session, cross-tenant view, missing role, diagnostics not configured) as a
  deliberate nudge towards enabling diagnostics upload.
- Tenant Admin on an unconfigured tenant gets a quick-config dialog: switches
  the tenant to Hosted + OnFailure (GET config -> patch two fields -> PUT
  verbatim), then queues rotate_config BEFORE request_diagnostics so the agent
  collects against the fresh config. Operators/Viewers never see the dialog.
- Operator may collect when configured (new TenantAdminOrOperator backend tier).
- useSessionTenantConfig picks up diagnosticsUploadConfigured from the
  member-readable feature-flags endpoint.
- Progress matching is sequence-based (backend-assigned) so agent clock skew
  and at-least-once action redelivery cannot produce false matches.

Pure state logic in collectLogsLogic.ts with vitest matrix (21 tests).
tsc clean; web suite 627/627.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:12:49 +02:00
Oliver Kieselbach 2fee50276e feat(backend): operator-tier session actions + diagnostics groundwork for on-demand log collection
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
  middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
  troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
  rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
  members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
  prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
  blob) so on-demand packages overwritten on the Sessions row by the terminal
  package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
  tenant-shared; operators would clear admin-tier notifications they cannot see.

Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:05:39 +02:00
Oliver Kieselbach 8bb02acc4a fix(web): bump postcss to >=8.5.12 (GHSA-6g55-p6wh-862q, high)
Dependabot alert #74: PostCSS <= 8.5.11 allows arbitrary file read /
information disclosure via an attacker-controlled sourceMappingURL in CSS
comments. Raise the floor (devDependency + override) from ^8.5.10 to
^8.5.12; the lockfile now resolves postcss 8.5.22 (its nanoid dep moves
3.3.11 -> 3.3.16 with it). npm audit reports 0 vulnerabilities.

Next.js pins postcss 8.4.31 transitively, but the existing root override
collapses the tree to a single postcss node, so the bump covers it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:54:57 +02:00
Oliver Kieselbach fd7d3973e7 fix(agent): esp_policy_provider_stalled gains sidecar-missing arm (issue #106)
Customer follow-up corrected the field-case mechanics: TrackingPoliciesCreated=1
WAS set under the foreign ConfigMgr provider - the ESP ignores a foreign
provider's completion values and waits on the Intune-registered "Sidecar"
provider by name (renaming the key unblocks instantly without creating any
tracking entries). The completeness-keyed detector would have missed exactly
the incident it was built for.

- Arm 2: >=1 Setup\Apps provider registered without Sidecar, continuously for
  the 15-min dwell -> reason=sidecar_provider_missing; clock resets when
  Sidecar registers later (legitimate startup ordering); DevicePreparation-only
  snapshots don't activate it
- Arm 1 (continuously-incomplete providers) unchanged -> reason=provider_incomplete;
  sidecar-missing wins when both arms cross in one round (single event)
- Cross-restart fingerprint gains a "sidecarMissing" sentinel; payload adds
  reason + sidecarMissingForMinutes
- Regression test: ConfigMgr complete + Sidecar absent -> fires (previously
  classified healthy); probe/doc headers and OKF doc corrected, web event-type
  description updated

Evidence (research session c7e3db08): Intune's ESP-policy WAP payload always
registers Sidecar (ConfigMgr only via co-management settings policy, per
Niehaus' documented payload); Microsoft's ESP troubleshooting models app
tracking exclusively through the Sidecar subkey.

Agent tests 2043/2043, web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:48:50 +02:00
Oliver Kieselbach fd2b5c2da2 fix(ci): drop invalid SWA input, gate deploy on token via env guard
Azure/static-web-apps-deploy@v1 has no `skip_deploy_on_missing_secrets`
input (confirmed against its action.yml), so passing it did nothing but
emit an "Unexpected input(s)" warning — the intended "skip green on
Dependabot PRs" behaviour was never actually in effect.

Replace it with the documented mechanism: surface the deploy token as a
job-level env var and gate the deploy/close step with `if: env.DEPLOY_TOKEN
!= ''` (secrets cannot be referenced directly in an `if:`). On Dependabot
PRs the token is absent, so the step skips and the job stays green; real
deploys on main and internal PRs still run. Applied to both jobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:38:58 +02:00
Oliver Kieselbach a44e37a7c2 fix(metrics): keep full failure-reason text for fleet-health display
BuildFleetFailureReasons truncated each reason to a 50-char prefix + "..."
and used that as both the grouping key AND the display value. The full text
never reached the UI, so its tooltip/tap-to-expand had nothing to reveal and
near-identical rows were indistinguishable past the cut.

Group by the 50-char prefix as before (variants still collapse into one row),
but carry the longest full reason as the display value. The UI truncates and
expands it on demand, so the complete text is now available end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:25:11 +02:00
Oliver Kieselbach d8a252562d fix(web): pin engines.node to 22.x so SWA/Oryx does not grab Node 24
The prior ">=22" made Oryx build with the highest available Node (24.13),
which the Static Web Apps runtime rejects: "Node version 24.13.0 is not
supported. Please use one of the following versions 18, 20, 22." Pin to
22.x so Oryx selects the SWA-supported major. CI setup-node '22' already
resolves to latest 22.x, so build and deploy now agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:23:17 +02:00
Oliver Kieselbach a5d12ba1d0 chore(ci): unify Node toolchain on 22 across CI + engines pins
Node 20 reached EOL (2026-04-30); Node 22 is the current maintenance LTS
and already the runtime the MCP container ships (node:22-slim). Straighten
the version skew so per-component CI major == deploy runtime == engines floor:

- ci.yml: web, mcp and guardrails setup-node 20 -> 22
- combine-rules.yml: setup-node 20 -> 22
- mcp package.json: add engines.node ">=22" (matches Dockerfile)
- web package.json: add engines.node ">=22" (first explicit pin; SWA/Oryx
  no longer picks a Node version implicitly)

engine-strict stays off (no .npmrc), so the pins are advisory and cannot
hard-break local installs on other Node versions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:15:54 +02:00
Oliver Kieselbach 530c978bf8 fix(web): TruncatedLabel expands to full text, not just two lines
The expanded state used line-clamp-2, which re-truncated long values with an
ellipsis after two lines — unreadable on touch where the hover tooltip is
unavailable. Expand now wraps the entire string over as many lines as needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:11:54 +02:00
Oliver Kieselbach 09898d7ba0 feat(web): consistent truncate-with-tooltip + tap-to-expand across dashboard cards
Add reusable TruncatedLabel component: a single-line label that truncates
with an ellipsis, shows the full text as a native hover tooltip, and on
click/Enter/Space expands to two wrapped lines (touch-friendly). An
interactive={false} mode provides tooltip-only behaviour for use inside
already-clickable rows/links where an inner onClick would hijack navigation.

Apply it consistently to the long, dynamic label columns across:
- fleet-health: failure reasons, model + app ranked lists
- sla: top failing apps, violators failure reason
- geographic-performance: fastest/slowest location cards
- vulnerability exposure panel: CVE sample software
- agent metrics: top exception types
- progress + download/install: app-name labels
- admin software tabs: software names
- session reports (comment/adminNote) + ops events (message): tooltip-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 15:03:34 +02:00
Oliver Kieselbach 71eda93e8b fix(metrics): app-install failure rate over finished installs only
Second half of the terminal-only rate convention (follows 1cfc32c9, which
converted the enrollment success rates): app-install failureRate divided
by ALL install rows including "InProgress" ones — installs still running,
or orphaned when a session died mid-install — diluting every failure rate
customers see on the Apps dashboard and Fleet Health.

- New MetricsMath.TerminalFailureRatePct (Failed / (Failed + Succeeded),
  0 when nothing finished) shared by both aggregation sites
- MetricsMath.BuildAppMetricsPayload: per-app failureRate terminal-based
  (Fleet Health "Top Failing Apps"; subtitle now "% of N finished")
- AppsAnalyticsHelper: list, analytics summary, versionBreakdown,
  timeSeries and deviceModelBreakdown rates terminal-based; the
  duplicated trend block is deduplicated into ComputeFailureTrend, whose
  rates and >= 5-sample gate now run on finished installs per half; the
  device-model >= 5 sample floor likewise counts finished installs
- Web: InstallsTab "Avg Failure Rate" card computed over summed finished
  installs instead of totalInstalls

Tests: new AppsAnalyticsFailureRateTests (list/trend/analytics/model
cases incl. in-flight dilution and gate regressions) and a terminal-rate
case in MetricsMathAppPayloadTests. Backend 3223/3223, tsc clean,
vitest 602/602.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:01:28 +02:00
Oliver Kieselbach 1cfc32c9aa fix(metrics): success rate over finished enrollments only (geo + fleet health)
Customer feedback: the geographic performance pane showed a poor success
rate for a site mid-rollout because in-progress/pending sessions counted
against the rate. Success rate is an outcome quota — align Geo and Fleet
Health with the SLA convention already used by dashboard stats, usage
snapshots and the fleet rollup: Succeeded / (Succeeded + Failed).
Incomplete (terminal, non-failure) stays excluded.

- Geo: per-location SuccessRate over finished sessions; table badge and
  map tooltip render an em dash instead of a red 0% when nothing has
  finished yet
- Fleet Health: Stats.SuccessRate terminal-based; card shows "X of Y
  finished enrollments" and a neutral em dash state; per-model health
  gets a Failed count (additive wire field) so the client rate excludes
  in-flight devices; TopFailingModels FailureRate now failed/finished
- Tests: FleetHealthPayloadTests flipped to the new convention plus
  zero-finished, model-failed and in-flight-dilution cases; new
  GeographicMetricsSuccessRateTests pin the geo semantics

Backend 3216/3216, web tsc clean, vitest 602/602.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:47:31 +02:00
Oliver Kieselbach 11339c9868 fix(test): de-flake SessionDeletionWorkerTests via condition-gated runs
CI run 30047512186 failed Worker_drops_envelope_missing_required_fields_
without_invoking_handler: RunForAsync starts the BackgroundService poll loop
and waits a fixed 500ms wall-clock window — on a loaded CI runner (xUnit runs
test classes in parallel) the loop may not have processed the message before
the positive assertions (DeleteMessageAsync at least once) run. All ten
positive tests in the file shared that latent race; this run it hit the
malformed-envelope drop test.

Fix mirrors the watchdog event-gate de-flake: new Harness.RunUntilAsync
(condition, 10s ceiling, 25ms poll) runs the worker until the awaited side
effect is observable, then stops. Gates: ops-event recorded (last step of the
poison path, so send+delete+CAS are already done), main-queue delete for the
drop tests, >=3 visibility heartbeats for the heartbeat test. The kill-switch
test keeps the fixed window — a Times.Never expectation has no event to wait
for. OpsEventCalls capture is now lock-guarded so the condition poll can read
it while the worker thread appends.

16/16 worker tests green across 4 consecutive runs; full backend suite
3211/3211.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:03:10 +02:00
Oliver Kieselbach 540f543834 feat(agent): extend historic IME-replay suppression to app events + app aggregation
Part 2 of the eaf3d8c4 fix (follows 088c3b04). In that session 147 of 157 app
events were replayed from a previous enrollment's IME log — all 10 "installed
apps" were phantoms, polluting the timeline, AppInstallSummaries, MCP counts,
the app_tracking_summary header, culprit lists and final-status.json.

Rename (nothing deployed yet): historic_script_replay_detected →
historic_ime_replay_detected; SuppressIfHistoricReplay / IsHistoricImeReplay.

Agent:
- EmitAppState suppresses stale-replay transitions BEFORE the sub-phase
  declaration, timing bookkeeping and terminal-signal dedup — covers the
  informational event, the AppInstallCompleted/Failed DecisionSignal, the
  shadow download_progress and the summary snapshot. Behavior note: phantom
  terminal signals can no longer spuriously re-arm the AdvisoryCompletion
  window (app signals are otherwise observation-only — verified no completion
  gate reads them). EmitDoTelemetry gets the same guard.
- Tracker-level guard at the HandlePatternMatch choke point skips the 23
  app-mutating actions (incl. espphasedetected — a stale AccountSetup line
  would make fresh DeviceSetup lines bounce as "backward") for source lines
  >24h stale, so replayed apps never enter PackageStates, persistence,
  app_tracking_summary, culprit lists or final-status. New internal
  UtcNowProvider seam (no ctor change), threshold single-sourced as
  ImeLogTracker.HistoricReplayThreshold.
- SimulationMode now bypasses all replay suppression — 088c3b04 had broken
  the --replay-log-dir dev tool, which feeds historic lines on purpose.

Backend: IsHistoricImeReplay guard in AggregateAppInstallEvent keeps replayed
events out of AppInstallSummaries (SLA/dashboards/metrics inherit).

Web: generic lib/historicReplay.ts partition (scriptExecutions becomes a
wrapper); InstallProgress hides replayed installs with a muted note;
DownloadProgress filters silently.

MCP: get_session_summary filters replayed events — cleans appInstalls stats,
errorCount (replayed app_install_failed are Error-severity) and keyEvents.

Tests: +13 agent (app suppression, tracker guard), +1 backend, +7 web,
+6 MCP. Agent 2039, backend 3211, web 602, MCP 455 all green; drift test
validates the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:43:59 +02:00
Oliver Kieselbach 088c3b0479 fix(agent): suppress historic IME-log script replay + timeline-consistent durations
Session eaf3d8c4: a previous enrollment's IME logs survived on disk (dated
2026-07-16, 7 days old) and the agent replayed them at startup — 156 phantom
script_completed events in a 7-minute session, each showing ~170h durations
(clock-clamped completion minus RAW stale start), plus an immediateUpload
flood (3x ingress_backpressure) and RemediationScriptCount inflated to 156.

Agent:
- Script events whose source line is >24h stale are suppressed entirely; a
  one-shot historic_script_replay_detected Info event (new EventType) dates
  the replayed window via earliestRejectedSourceTimestamp. Future-skew
  rejections and clock-only fallbacks still emit.
- durationSeconds now uses a timeline-consistent pair: on rejected-timestamp
  emissions the raw source completion pairs with the raw start (true runtime
  instead of cross-timeline garbage). 24h plausibility backstop omits
  implausible values; same consistency for script_timeout_suspected.
- Stale pending platform-script slots (>24h older than the current start
  line) are discarded so an old orphaned start can't pair with a fresh run
  (extracted HandlePlatformScriptStarted as internal test seam).

Web: partitionHistoricScriptEvents hides legacy-agent replay events (muted
"N historic executions hidden" note) and the reducer blanks >24h durations —
covers sessions recorded before the agent fix rolls out.

Backend: IsHistoricScriptReplay guard keeps replayed events from inflating
Platform-/RemediationScriptCount during the rollout window.

Registered historic_script_replay_detected in Constants.EventTypes, MCP
resource-catalog (script_events) + KEY_EVENT_TYPES, gather-rules eventTypes.

Tests: 8 agent (suppression/one-shot/raw-pair/backstop/slot-hardening),
8 web (partition/blanking), 10 backend (predicate). Agent 2026, backend
3210, web 595, MCP 449 all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:39:11 +02:00
Oliver Kieselbach 3224774752 fix(test): EDR-safe straggler replay — no more renamed system executable
The straggler integration test copied powershell.exe under a synthetic
agent name and launched it — textbook MITRE T1036.003 ("Rename Legitimate
Utilities"). Defender for Endpoint flagged the run ("System executable
renamed and launched", initiating process testhost.net48.exe) and ISOLATED
the dev machine.

Replaced with function shadowing: a harness script defines Get-Process /
Stop-Process stub functions (PowerShell resolves functions before cmdlets)
and dot-sources the cleanup script. The stubs report a live agent-named
straggler on every probe and record the kill, pinning the identical
contract — poll by NAME until the deadline, force-kill by NAME, then
delete — with zero process spawns, zero PE copies, nothing EDR-visible.

The production routine never renames or copies executables; the flagged
technique existed only in this test. Lesson recorded in tasks/lessons.md.

22/22 cleanup tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:24:07 +02:00
Oliver Kieselbach e7ea431ccf feat(esp): recovery story for terminally reported ESP failures (session 4910a5a5)
DeviceSetup/Apps failed, the user pressed "Try again" ~23 min later, apps
recovered to 10/10 and DeviceSetup resolved - yet the advisory_completion
window un-defanged the stale failure and failed the live enrollment,
because the advisory variant was exempt from all re-arm/rebase guards.

Engine fix (DecisionCore):
- New facts EspAdvisoryFailureResolvedUtc + EspAdvisoryFailureCategory
  (state-borne; deadline payload would be lost on re-arm/rebase).
- TryResolveAdvisoryOnCategoryRecovery in both ProvisioningComplete
  handlers: category match sets the fact + emits
  esp_failure_advisory_resolved.
- Guards now (!hasAdvisoryAnchor || advisoryResolved): enforcement-progress
  re-arm, hello-never-observed promote and the reboot rebase apply again
  once the failure demonstrably un-happened; unresolved advisories keep
  the legacy un-defang exactly.
- Truthful terminal reason esp_recovered_without_completion_evidence
  (+ failureRecovered=true, LastFailureTrigger=DeadlineFired so the
  likely-stuck app promotion stays off).

Story events (agent tracker):
- esp_failure_retry_detected: failed subcategory leaves "failed" after the
  terminal fire - consistent with the user pressing "Try again".
- esp_failure_recovered: previously failed category reaches success.
- EspFailureDetectedEventArgs.LikelyCulpritApps (never-started first,
  cap 3) rides the EspTerminalFailure payload so esp_failure_advisory /
  enrollment_failed name the culprit app directly.

Surfacing: MCP resource-catalog, web timeline badges (Recovered /
User retry pills, culprit chip on advisory), gather-rule autocomplete,
ANALYZE-ESP-006 "ESP Failure Recovered After User Retry".

Tests: AdvisoryRecoveryTests (13, incl. the 527/611/635/671 field-ordinal
replay that no longer fails the session), EspFailureRecoveryStoryTests (5),
snapshot-builder guards 18 -> 20. All suites green (DecisionCore 493,
V2.Core 2018, Backend 3201, web vitest 589, MCP 449 incl. drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:03:44 +02:00
Oliver Kieselbach 9e24b92fbf feat(agent): ESP policy-provider stall detector + Autopilot registry guardrail
Customer field case: pre-provisioned devices with the ConfigMgr client as a
blocking app occasionally park the user ESP at "Apps (Identifying)" for days.
Root cause (verified against the EnrollmentStatusTracking CSP doc): only a
"ConfigMgr" provider is registered under Device\Setup\Apps\PolicyProviders
(no "Sidecar" = IME) and TrackingPoliciesCreated never becomes 1 - a wait the
CSP defines WITHOUT any timeout. Renaming the key to Sidecar unblocks
immediately. Microsoft documents that ESP providers are not aware of each
other and that pre-provisioning is unsupported with co-management.

New always-on esp_policy_provider_stalled tripwire, generalized to any
provider in both kinds (Setup/Apps TrackingPoliciesCreated != 1,
DevicePreparation InstallationState not in {2,3}):

- EspPolicyProviderProbe: fail-soft registry read of the
  EnrollmentStatusTracking mirror (device + user-SID scopes), defensive
  value parsing (missing/unparseable = incomplete), ScopedOverride test seam.
  Research and CSP contract quotes captured in the XML doc header.
- EspPolicyProviderStallDetector: pure EvaluateStallTransition on a 15-min
  continuous-incompleteness dwell (resets on completion/disappearance;
  absent key = normal early enrollment), one-shot per provider,
  StartupEventGate fingerprint over the sorted stalled key set for
  cross-restart dedup. Warning + ImmediateUpload via InformationalEventPost;
  purely observational, no decision-engine coupling, no config toggle,
  no ConfigVersion bump (disk_space_low precedent).
- EspPolicyProviderStallHost: 60-s wall-clock tick, deliberately outside the
  StallProbe idle gate (idle clock resets on session activity) and the
  idle-stopped periodic collectors.
- Registered in Constants.EventTypes, MCP resource-catalog (health_events),
  web gather-rule autocomplete (stall category).

Guardrail companion: SOFTWARE\Microsoft\Windows\Autopilot joins the
gather-rule registry allowlist ("Autopilot / OOBE / Setup") so tenants can
build custom rules on the subtree (e.g. not_exists on the Sidecar provider
key); generated web mirror regenerated via combine.js; positive + sibling-
spoof guard tests added.

Docs: OKF concept doc agent/esp-policy-provider-stall.md (+ index/log).

Tests: 22 new detector tests (pure transition matrix + emission surface incl.
the ConfigMgr-only customer scenario and gate-across-restart dedup); agent
suite 2013/2013, web vitest 589/589 + tsc clean, MCP 449 passed incl.
event-types-drift.

Both the detector and the embedded allowlist take effect with the next agent
release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:20:47 +02:00
Oliver Kieselbach 45d7c03ccf fix(web): WhiteGlove per-block durations use sequence-canonical edges, not timestamp min/max
Customer report (session fcb44595): dashboard said 115 min, session detail
said 5h 2m for the same pre-provisioned enrollment. The detail's per-block
duration calc took min/max over ALL event timestamps inside each
sequence-block, which two real-world timestamp shapes break:

- an app_install_started re-emitted by the ImeLogTracker after the Part-2
  resume legitimately carries its historical Part-1 event time -> the User
  Enrollment block stretched back across the sealed pause (56m -> 3h18m)
- a do_telemetry with a +1h source timestamp (IME log line with a wrong
  timezone bias, just under the agent's 1h future-skew clamp) stretched
  Pre-Provisioning (58m -> 1h44m)

Sum: exactly the "5h 2m" the customer saw.

Block spans are now anchored on the first/last event BY SEQUENCE (sequence
is the canonical order), with Part 1 starting at session.startedAt for
backend DurationSeconds parity. Extracted as pure helper
computeWhiteGloveDurations with tests modeled on the real session; replay
against the customer's 880 events yields 1h 54m == backend 6872s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:26:33 +02:00
Oliver Kieselbach 90012d9a66 feat(agent): verify Secure Boot CA-2023 state against firmware, not just servicing registry
Customer report: ANALYZE-SEC-001 warned on devices whose firmware already
carried the Windows UEFI CA 2023 certificate — the agent only read the
SecureBoot\Servicing registry value, which mirrors the Windows Update
rollout and lags behind reality (OEM factory image, WinCS, WU not run yet).

Agent: new UefiSecureBootCertReader reads the UEFI db/KEK variables
natively (GetFirmwareEnvironmentVariableExW; SeSystemEnvironmentPrivilege
enabled on the process token and restored — first privilege-adjustment
code in the repo). Fail-soft, never throws; legacy BIOS maps to not_uefi.
secureboot_status gains per-certificate booleans, uefiFirmwareReadStatus,
and the one-sided marker uefiCa2023FirmwareConfirmed (emitted only on
positive confirmation, never as false).

Rule v3.0.0: not_exists precondition on the marker suppresses the rule
when the firmware confirms the certificate; the marker's absence keeps
old agents on exact v2 behavior, so the rule is safe to seed before the
agent rollout. Firmware-verified-missing raises confidence (+20 db,
+10 KEK 2K CA 2023).

Verified: full solution build; backend 3201, agent 1988, web 582 tests
green; tsc/ajv clean; elevated cross-check on real firmware matches
Get-SecureBootUEFI on all seven certificate probes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 14:07:01 +02:00
Oliver Kieselbach e8ef4f53ae feat(agent): OOBE-state observability via WinRT SystemSetupInfo
Sample Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState
(build >= 17763, SYSTEM-callable, CLR-native WinRT projection — no NuGet)
as a purely observational signal, mirroring the bootstrap guard marker:

- oobeStateAtAgentStart on agent_started and agent_late_start payloads:
  makes late starts provable instead of heuristic ("completed" on a
  first_run = OOBE was already over when the agent arrived) and validates
  the bootstrap OOBE guard fleet-wide.
- One-shot oobe_state_completed trace event on the in_progress->completed
  flip, piggybacked on the DesktopArrivalDetector 30s poll: an
  owner-independent desktop corroboration for DAD-starved sessions
  (4d5a0b78 class — WTS+WMI owner resolution dead, desktop_arrived never
  fires). Often absent in healthy sessions since the DAD timer is
  disposed at desktop arrival, which is when the flip lands.

OobeStateReader is fail-soft (never throws, "unavailable" on missing
contract); the flip check has its own try/catch so it cannot pollute the
DAD wmiErrorCount liveness diagnostics, "unavailable" never overwrites
the baseline, and the one-shot flag survives ResetForRealUserSwitch.
No decision-engine involvement, no config/backend changes. Event type
registered in Constants.EventTypes, MCP resource-catalog (phase_events)
and web gather-rule autocomplete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:39:10 +02:00
Oliver Kieselbach a0d10b8471 test(cleanup): end-to-end incident replays for the self-destruct script
Six integration tests execute the generated cleanup script with a real
powershell.exe against GUID-unique scratch trees — proving what the string
pins cannot:

- locked tree ⇒ complete abort, marker + binaries survive (the 2026-07-22
  regression: the old script force-deleted around the locks)
- full incident replay: a renamed second process holds a file in the tree,
  is found by NAME, force-killed after the wait deadline, cleanup then
  completes with nothing left behind
- fresh foreign lock ⇒ loser backs off without touching the tree; stale
  lock (>10min) is taken over and cleanup proceeds
- happy path + keepLogs variant

BuildCleanupScript gains test-only timing parameters (defaults unchanged
for production) so the replays run in seconds. Agent suite 1969/1969.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 10:06:29 +02:00
Oliver Kieselbach ad3632e9fb fix(emergency-break): survivable self-destruct, reliable break report, ops visibility
Incident 2026-07-22: the 48h emergency break left exe + loaded DLLs orphaned
forever — a second agent instance held the images while the cleanup script
(waiting on the launcher PID only) force-deleted marker + state around the
locks AFTER removing the Scheduled Task, destroying the retry path.

Agent — cleanup script (LIFE-F7):
- wait for ALL agent processes by name (PID-reuse-proof), then force-kill
  stragglers; single-flight lock file serializes concurrent cleanup scripts
- probe-rename (GUID target) BEFORE task removal; any failure renames back
  and aborts with task + marker + files intact for a next-boot retry; the
  destructive Remove-Item-on-original fallback is gone
- script generation extracted to BuildCleanupScript, pinned by 13 tests;
  all 4 variants parser-validated + E2E-smoked (happy path, locked tree)

Agent — break report delivery:
- EmergencyReporter counts DELIVERED reports: reservation rolls back on
  failure (mark-before-send permanently suppressed a key after its first
  transport failure); optional in-reservation retries
- break path: NIC wait <=15s, 3 attempts x 15s timeout (survives Functions
  cold starts observed as >5s 503s), 75s overall bound

Backend:
- backend-materialized events (agent_emergency_break, session_timeout) now
  upsert the cross-session EventType index — they were invisible to every
  search-by-eventType surface (portal + MCP)
- new AgentEmergencyBreak ops event (Agent/Warning, once per session) so
  operators see silently-lost agents; dual-registered in OPS_EVENT_TYPES
- materialization extracted to a static seam, covered by 4 new tests

Agent 1963/1963, Backend 3198/3198, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:56:46 +02:00
Oliver Kieselbach 05e1569503 feat(agent): wildcard support for Local Admin Analyzer allowed accounts
Allowed Local Accounts entries may now contain glob wildcards (* / ?),
matching the hardware-whitelist semantics: Regex.Escape conversion,
case-insensitive, 100ms ReDoS timeout. Applies to both the account and
the profile-folder check. Entries without wildcard chars keep exact
matching; * and ? are invalid in Windows account names, so patterns and
literals cannot collide. UI documents the syntax and rejects patterns
with fewer than 2 literal characters (a bare * would disable the
analyzer). No schema change - old agents treat patterns as literals and
keep alerting (fail-safe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:47:13 +02:00
Oliver Kieselbach ba3581e253 bootstrapper added download stopwatch 2026-07-23 09:35:30 +02:00
Oliver Kieselbach 829ec4c969 fix(release): stamp Cache-Control no-cache on every blob upload
The first release after the download-alias migration exposed Front Door
caching as a correctness hazard: the alias kept serving the previous
version.json (X-Cache: TCP_REMOTE_HIT) after the origin had rotated to
2.0.1370, pinning the portal's version badge to the old build. The dangerous
variant is worse than a stale badge -- manifest and ZIP are independent cache
entries, so a stale manifest paired with a fresh ZIP fails the bootstrap's
SHA-256 integrity check and the install dies.

Route caching on /agent/* has been disabled (infra side); this commit is the
defense-in-depth half: every upload now carries x-ms-blob-cache-control
no-cache -- the CI Send-Blob (eu and legacy mirror alike), the local build.ps1
counterpart (gitignored, changed in lockstep), and the versions/*.json
manifest publishes. Uniform no-cache rather than an immutable/mutable split
because every blob in the agent container rotates in place, including the
per-line AutopilotMonitor-Agent-v2.zip -- nothing there is safely cacheable.
Existing blobs get the header with the next release's re-upload.

Log/docs updated in url-registry.md; no code paths changed, YAML and ps1
parse-checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:17:02 +02:00
Oliver Kieselbach b2d2d37c6f feat(realmjoin): detect aborted RJ ESP (missing 110) and release the completion gate
An interactive logon during the RealmJoin first deployment (phase 100/101)
makes RJ reclassify the run as a secondary-user deployment, whose branch
writes 200/210 and never CompletedFirstDeployment (110) — no crash, no
reboot request. The V2 completion gate waited for exactly 110, so these
sessions hung until the 60-min hard timeout (session 224b2087; siblings
558bf067/3cc18724 delayed ~50 min the same way).

- promote realmjoin_phase_changed to a typed RealmJoinPhaseChanged signal
  (dual-emitted by the adapter) so RealmJoinFacts.LastDeploymentPhase is
  persisted across agent restarts
- abort rule: prev in {100,101} and current in {200,210} with no outcome
  yet -> emit realmjoin_first_deployment_incomplete (Warning), cancel the
  hard-timeout deadline, release the gate with outcome
  FirstDeploymentIncomplete; also evaluated on the RealmJoinDetected
  replay path (restart swallows the transition)
- deliberately NOT triggered by 101->0 (RJ service restart may still
  complete the first deployment) nor by a first observation of 210
  (session 6f1959c0: RJ already completed before agent boot)
- fix realmjoin_timeout always claiming "last phase: 0" - the persisted
  phase now feeds the message
- backend EnrollmentTimeoutClassifier counts the new event as a gate
  terminal; MCP resource catalog dual-synced
- CompleteIfDeferredOrBookkeep widened to a leading-effect list

Verified by replaying session 224b2087's signal log: gate releases at
15:05:17 (101->200) instead of hanging until the 15:54 deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:14:30 +02:00
Oliver Kieselbach e0ad51b965 refactor(urls): one guard-enforced URL registry per component, agent downloads move to the alias
Every well-known host -- own subdomains, both blob accounts, the API origin,
Graph, Entra login -- now lives in exactly one registry per component:
Constants.cs (*BaseUrl) for C#, utils/config.ts for the web app, src/config.ts
for the MCP server. lib/hostRouting.ts derives its hostnames from the registry
and middleware.ts imports from it, retiring what turned out to be a fourth
independent copy of the www/portal host pair.

Three new guard tests (backend xUnit, web vitest, MCP vitest) scan non-test
source and fail on any enforced host outside its registry. This is the lesson
of the EU cutover: the registry existed then too, but LatestVersionsService
(feeds the portal's version badge) and ValidateBootstrapCodeFunction carried
their own literal copies of the legacy blob host and were missed -- kept
working only by the fail-soft legacy mirror in build-agent.yml, whose silent
failure would have pinned badge and self-update to stale bits with a green
release. Both guards proved themselves on their first run, flagging the
middleware host copy and a literal in an MCP tool description. Test files stay
exempt on purpose: literals there are independent oracles, and
ValidateBootstrapCodeResponseShapeTests now pins the full alias URL against
the Constants-built one so registry changes must be acknowledged consciously.

The two legacy readers plus the agent SelfUpdater now use
Constants.AgentDownloadBaseUrl (download.autopilotmonitor.com/agent); the
alias was verified live for version.json, version-v2.json and both ZIPs
before the switch. The portal's bootstrap allow-list accepts the alias AND
the legacy host (AGENT_DOWNLOAD_HOSTNAMES) for already-deployed customer
scripts, which imposes a deploy order: web before or with backend. The
legacy keepalive probes and the release mirror stay untouched -- they exist
for exactly this transition. Also: the bootstrap short-link now targets www
directly instead of riding the registrar's apex 301, and the welcome-mail
docs button points at docs. instead of bouncing through www./docs.

Suites: backend 3194, agent core 1925, DecisionCore 474, web 582, MCP 449 --
all green; next build verified (it, not tsc, proves the next.config.ts
registry import). Docs: docs/url-registry.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 08:37:42 +02:00
Oliver Kieselbach bbb55034e3 feat(versioning): one version contract for agent, backend and MCP
All three components now report <major>.<minor>.<build> plus a 7-char commit
and a build timestamp. The backend had been pinned at a hand-written 1.0.0
since inception and starts at 1.5.1000; the MCP server's package.json version
was bumped by hand and starts at 1.5.300, and it now reports its own commit on
/health instead of only the documentation bundle's.

The build number keeps coming from a counter blob reserved before the build
with ETag-CAS, but there is now one implementation instead of two copies:
.github/scripts/Request-BuildNumber.ps1, called by all three workflows and by
the local build.ps1. It lives under .github/ because scripts/Deployment/ is
gitignored (those scripts carry SAS tokens) and would therefore be absent from
a CI checkout; scripts/Build/ and scripts/Release/ are shadowed by the
[Bb]uild/ and [Rr]elease/ artifact ignores.

The counter cannot be replaced by the published manifest, which is the obvious
simplification to reach for: version.json is written only on stable publish, so
two consecutive dev builds would derive the same number and the second would
overwrite the first's artifact. git rev-list --count fails the same way, two
builds of one commit collide.

Both deploy workflows now poll their health endpoint until it reports exactly
the version and commit just deployed, and publish versions/<component>.json
only then. A green deploy step alone means the package was accepted, not that
it is being served -- so a manifest that disagrees with the live endpoint now
means the running instance drifted (rollback, stale revision) rather than a
pipeline that stopped halfway. The agent manifest gains the same commit and
buildUtc fields additively; the bootstrap reads it by field name, so older
installers ignore them, and sha256/bootstrapVersion stay untouched.

Requires the versions container plus AZURE_BLOB_VERSIONS_SAS_TOKEN and the two
seeded counters (backend 999, mcp 299) before the first deploy run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:41:03 +02:00
Oliver Kieselbach 58c8cf87a4 updated command 2026-07-22 15:38:02 +02:00
Oliver Kieselbach 71d2c0e47f fix(maintenance): remove the time-window auto-block, it never measured data volume
GetExcessiveDataSendersAsync gated on LastEventAt - StartedAt alone. No event
count, no bytes, no rate — the name asserted something the code never checked.
What it actually selected was every enrollment session that spanned a night,
which for Autopilot is the ordinary case: device unboxed in the evening, ESP
runs, machine powered off, user signs in the next morning.

The per-device alerts from the previous commit made this visible. All three
devices blocked at the time of the analysis were false positives:

  CND3320949 / 64ed21e8    23 events over 15.5h  (1.5 events/hour)
  5CG4303CPT / db5751bc   192 events over 16.8h  (blocked mid Account Setup 1/5)
  PF5YANFB   / 2069de87   781 events over 31.5h

14 such blocks in 30 days across 9 customer tenants. Over the same window the
event-count detector fired 5 times and was right every time (2742, 2714, 2712,
2506 events). Each false block costs a live enrollment 8 hours of telemetry.

Repairing it rather than removing it does not pay: once a volume condition is
added, "many events AND long span" is a subset of "many events", which is what
the count detector already does. Volume is the quantity that costs anything; a
23-event session spanning 15 hours costs nothing. So the detector, its two admin
settings, the ExcessiveDataBlocked ops event and the portal section go, and the
event-count path is the only automatic block left.

MaxSessionWindowHours and MaintenanceBlockDurationHours are deleted rather than
deprecated: the config row is written with Replace, so the columns drop out on
the next portal save, and nothing reads them meanwhile. A comment on the
surviving detector records why there is no second one, so it does not get
rebuilt a year from now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 15:28:28 +02:00
Oliver Kieselbach 3b2f5ed46d feat(runaway-detection): name the device in the time-window alert, link it in the block list
The two runaway detectors emitted alerts of unequal usefulness. The event-count
path fires per session and carries sessionId + serialNumber, so the Ops Events
detail modal can offer View session / Block / Kill. The time-window path
aggregated a whole tenant into one event with {devicesBlocked, windowHours} —
no device, no session, no shortcuts, on precisely the alert where an operator
wants to act. It now writes one event per blocked device with the same payload
shape; the aggregate count stays in the audit entry, where a sum is the useful
view.

The Active Blocks table showed the serial as plain text although the API already
returns BlockedSessionIds. Maintenance blocks now link the serial to the session
that triggered them; manual whole-device blocks have no session and stay text.

Two defects in the event-count path, found while comparing the detectors:

- No isBlocked guard, so the auto-action re-blocked an already-blocked device and
  overwrote BlockedAt/UnblockAt/Reason of the existing block — usually the
  time-window block on the same session, whose reason then vanished from the list.
- No status filter, so a session that finished days ago with a high EventCount
  still cost its device a 24h block. The fix needed a layer below the obvious one:
  the runaway query never selected Status, and SessionStatus.InProgress is
  ordinal 0, so a guard on the unpopulated field would have been silently
  always-true. ParseStatusForAutoAction is stricter than MapToSessionSummary --
  Enum.TryParse maps "0" onto InProgress and ORs comma-separated names into an
  unrelated member, both of which would open the gate.

Not addressed, and deliberately: a block is session-scoped and stops the upload,
so whichever detector fires first freezes the other for that session. With
MaxSessionWindowHours=12 the time detector wins in practice. That is correct
behaviour, not a defect; merging the detectors would be its own change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:43:00 +02:00
Oliver Kieselbach 7329f12217 ci(codeql): skip analysis on documentation-only changes
CodeQL analyzes C# and TypeScript, so a commit touching only docs or markdown
cannot change its result - it spent ~10 minutes (8:50 for C#, 1:32 for
TypeScript) re-reporting an unchanged outcome.

paths-ignore on push and pull_request only. The weekly cron stays unfiltered:
it exists to catch advisories published against code that did not change, and
it doubles as the backstop should the filter ever skip too much.

A commit touching docs AND code still runs - GitHub skips only when every
changed path matches the filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:10:18 +02:00
Oliver Kieselbach c9a9df99f1 docs(ci): describe what the CI workflow actually does
The header claimed it "runs the full test suite" - it also typechecks the web
and MCP projects and fails on drift between rules/guardrails.json and the
generated rule files. It also called itself "the gate that decides whether a
change is allowed to be correct", which was both garbled and false: main is
deliberately unprotected, so a red run reports rather than blocks.

Comment only, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:10:17 +02:00
Oliver Kieselbach 757427ffea chore(build): clear the compiler and xUnit analyzer warnings CI annotates
The CI test job surfaced 10 annotations, but GitHub caps them at 10 per job —
a full rebuild showed 31 nullable warnings plus 26 xUnit analyzer warnings.

Production code:
- CertificateValidator: X509Certificate2(byte[]) is obsolete (SYSLIB0057) ->
  X509CertificateLoader.LoadCertificate.
- AuthenticationMiddleware: IsValidTenantId now carries [NotNullWhen(true)], so
  the guard above BuildTenantAuthority is visible to flow analysis.
- AgentEndpointMigrationRules: the out params are genuinely nullable on the
  false path; netstandard2.0 has no usable [NotNullWhen], so the contract is
  documented instead.
- Constants.ClassifyEspAppsFailure: errorCode is optional by design (the
  "no HRESULT" branch is the timeout fallback) - signature says so now.

Tests: nullable annotations on helper signatures, Assert.Single/DoesNotContain
overloads instead of pre-filtering with Where, and [InlineData(null)] theories
declare nullable parameters. GatherRule helpers now seed Parameters with the
model default (empty) rather than null, matching a deserialized rule.

No behaviour change. 5616 tests pass; solution builds warning-free apart from
the two pre-existing NU1510 pruning notices on the pinned security packages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:09:11 +02:00
Oliver Kieselbach 71832b9deb docs(about): list hardware rejection among the webhook alert types
The integrations list named start, success, failure and SLA alerts but omitted
hardware rejection, understating what tenants can actually subscribe to.

Verified before claiming it: WebhookNotifyOnHardwareRejection is wired
end-to-end - model, portal toggle in SectionHardwareWhitelist, and dispatch in
ReportDistressFunction. This list occurs nowhere else in the repo or in
autopilotmonitor-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach ce4d884abc test(gather-rules): drive the collectors through their guards
The guard functions had unit tests, but nothing proved that Registry, Wmi,
Json, Xml and File actually consult them before touching the registry, WMI or
disk. These drive the collectors themselves: each blocked target must come
back empty, emit exactly one security_warning naming the target, and produce
no rule output. Prefix-spoofing included, plus a positive case through
JsonCollector so it is pinned that the guard is a gate and not a wall.

Path traversal had no explicit coverage either. The sharpest case is
...\Logs\..\..\..\..\Secret.txt - the RAW string starts with an allowed
prefix, so a naive StartsWith would admit it; the test pins that
Path.GetFullPath canonicalizes first and the allowlist sees C:\Secret.txt.
Also traversal into the C:\Users privacy block and into System32\config, both
asserted under unrestrictedMode, which lifts the allowlist but never those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach b56c774103 fix(logging): strip Unicode line separators in LogSanitizer
The sanitizer matched \p{Cc}, which covers the C0/C1 control ranges but not
U+2028 LINE SEPARATOR or U+2029 PARAGRAPH SEPARATOR - those are categories
Zl/Zp. A log viewer, a JSON-lines consumer or any JavaScript-side reader still
breaks a line on them, so they were a residual log-forging vector.

Matching \p{Zl} and \p{Zp} rather than listing escapes: the two categories
contain exactly those two characters, and it keeps the source free of
invisible literals.

Verified the test is not vacuous - the old pattern left "a<U+2028>b" as "a b",
the new one yields "ab".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:48 +02:00
Oliver Kieselbach 68767f436f fix(config): validate ContactEmail and seed it conditionally
The tenant contact address had no server-side validation, so a direct API
caller could store any string in the field service notices are sent to.
UpdateTenantConfiguration now rejects non-addresses, recipient lists (a comma
would silently widen who receives those notices), display-name forms and
control characters (mail-header injection), and normalizes what it stores.
Empty stays valid - having no contact address is a legitimate state.

The seed path had a worse problem than missing validation. It was a
read-modify-write of the whole model through SaveTenantConfigurationAsync,
which upserts with TableUpdateMode.Replace and no ETag: a concurrent portal
save was clobbered by the seeder's stale snapshot of EVERY field, not just
ContactEmail. It also wrote straight through IConfigRepository, bypassing the
TenantConfigurationService cache, so a seeded address stayed invisible for the
full 5-minute TTL.

TrySeedTenantContactEmailAsync now writes ETag-conditionally in Merge mode -
one property, and a 412 means someone else wrote the row so the seed abandons
rather than overwriting the winner. A lost seed is recoverable on the next
maintenance run; a lost admin edit is not.

The same seed logic existed twice (preview notification-email save and the
maintenance backfill). Both now call a single owner on
TenantConfigurationService, which holds the never-overwrite invariant, the
cache eviction and the fail-soft guarantee. Fail-soft lives in the service
rather than the repository so it holds for any implementation.

Tests cover the validator, the service contract, and the repository against a
mocked TableClient: Merge mode, the ETag actually passed, only ContactEmail in
the patch, the 412 race, 404, storage faults. Plus a regression pin that
SaveTenantConfigurationAsync is never called from the seed path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:30 +02:00
Oliver Kieselbach c7b06be8b7 fix(ratelimit): key the user throttle on CallerId, not the UPN
Follow-up to 479247cd. Publishing only a genuine UPN closed the fleet-wide bucket
collapse but opened a narrower hole: AuthenticationMiddleware validates issuer,
audience, lifetime and signature but requires neither upn nor scp, so an app-only
(client-credentials) token authenticates and reaches every AuthenticatedUser route
(feedback, realtime/negotiate, progress/*, health/detailed, auth/mcp, ...). Such a
token carries no upn, so it resolved to an empty identity and UserRateLimitMiddleware
skipped it entirely — previously it at least landed in the shared anonymous bucket.

Rather than reinstating a placeholder as a bucket key, separate the two concepts:

- RequestContext.UserPrincipalName keeps its meaning — a real UPN or nothing. Audit,
  presence and admin flows continue to see only genuine user identities.
- RequestContext.CallerId is the throttle identity: UPN, else the token subject
  (oid -> sub -> appid/azp), else a fail-closed shared bucket. Empty means exactly one
  thing — no JWT at all (device/anonymous routes, which carry their own limits).
- UserRateLimitMiddleware gates and buckets on CallerId.

This matches how McpQuotaEnforcementMiddleware already keys its quota (oid), so the
subject-based identity is the existing house convention for enforcement.

10 new tests: the oid/sub/appid/azp fallback chain and its precedence, the fail-closed
bucket for a token with no identifying claim (asserting it is never "anonymous"), and
that empty CallerId occurs only on unauthenticated routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:17:34 +02:00
Oliver Kieselbach 479247cdae fix(ratelimit): stop agent fleet sharing one anonymous user bucket
Device and anonymous routes published the "anonymous" placeholder as
RequestContext.UserPrincipalName. UserRateLimitMiddleware skips only on an EMPTY UPN, so
every agent of every tenant was bucketed under user_ratelimit_anonymous and throttled
against the 120/min per-user limit. On 2026-07-21 21:00-21:30 UTC that rejected 1821
agent requests with 429 (/api/agent/telemetry, config, register-session, error); the
7-day trend shows it recurring and growing with fleet size.

- PolicyEnforcementMiddleware: RequestContext.UserPrincipalName carries a genuine JWT
  UPN or nothing. decision.UserIdentifier keeps the placeholder for log lines.
- Device/bootstrap routes already hold their own limit in SecurityValidator (per cert
  thumbprint / per bootstrap token), so they lose nothing.
- stats/platform and diagnostics/download had NO limiter of their own and were riding on
  the accidental shared bucket. Both now limit per trusted client IP (60/min and 30/min)
  via ClientIpExtractor, matching the HealthCheck pattern.
- 13 new tests pin the empty-UPN contract across all 12 device/anonymous routes, plus a
  guard that an authenticated caller still surfaces its real UPN.

Solution suite 5552 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:48:33 +02:00
Oliver Kieselbach b6ba87f072 fix(deps): pin patched DataProtection + Cryptography.Xml to clear NuGet advisories
Microsoft.Identity.Web 4.3.0 pulls Microsoft.AspNetCore.DataProtection and
System.Security.Cryptography.Xml transitively at 10.0.0, which carry known
advisories (GHSA-9mv3-2cwr-p262 critical on DataProtection; GHSA-37gx-xxp4-5rgx
and siblings, high, on Cryptography.Xml). Add explicit references pinning both
to the patched 10.0.10 servicing build (matching the existing JwtBearer pin) —
the NuGet equivalent of the npm overrides used for the web/MCP alerts.

Pinned in the production Functions project so the fix flows to the deployed
artifact and to Functions.Tests via project reference. `dotnet list package
--vulnerable` now reports 0 across every project; build clean; 3110 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:44:44 +02:00
Oliver Kieselbach fa640ebe61 fix(security): resolve CodeQL log-forging findings and suppress by-design queries
Bucket B (real fixes) + Bucket A (config) of the CodeQL alert triage.

Log-forging (cs/log-forging, 8 sinks): add Helpers/LogSanitizer that strips
CR/LF and other control characters, and apply it to the logged copy of every
user-controlled value in AuthenticationMiddleware, PolicyEnforcementMiddleware
(Invoke + DecideAsync), McpQuotaEnforcementMiddleware and TableUserUsageRepository.
Only the logged value is sanitized — routing, authorization and storage keys are
untouched, so tracing fidelity for legitimate values (paths, UPNs, GUIDs) is
preserved. Structured logging already isolated the fields; this closes the CRLF
vector as defense-in-depth and clears the query.

Three genuine JS/TS fixes:
- guardValidation.ts: replace the no-op `.replace(/%/g, "%")` with a real
  escapeRegExp() — the author intended to escape; this also closes the latent
  metacharacter gap (js/identity-replacement).
- access-guard.ts: use a `%s` format argument instead of interpolating the UPN
  into the console.error format string (js/tainted-format-string).
- docs-corpus.ts: strip HTML tags with a loop-until-stable stripHtmlTags() so the
  sanitizer is robust and recognized as complete (js/incomplete-multi-char-sanitization).

CodeQL config: filter the two queries that fire structurally on this codebase,
where the flagged behavior is the feature — cs/exposure-of-sensitive-information
(audit/notification logs legitimately record the actor UPN / recipient email) and
cs/cleartext-storage-of-sensitive-information (rate limiters key on the trusted
client IP). Default suites stay active; genuine one-off false positives from other
queries are dismissed individually so those queries stay sharp.

Tests: Backend xUnit 3110 (+9 LogSanitizer), MCP vitest 19 (+1 tag-strip),
Web vitest 49, all tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:38:47 +02:00
Oliver Kieselbach dbc20a9f66 test(deletion-maintenance): de-flake watchdog tests via event gates
The three watchdog tests raced a fixed fanout-body sleep against sub-second
warn/severe thresholds (e.g. 150ms body vs 500ms severe). On a loaded CI
runner the whole RunCoreAsync body overran the severe threshold before the
finally-block cancelled the watchdog, so the severe OpsEvent fired and
"LongRunning past warning" flaked (3100/3101).

Replace the wall-clock race with event gates: the fanout body now blocks on
WaitForEventAsync(<watchdog OpsEvent>) instead of sleeping, so the target
watchdog is guaranteed to have fired before the body returns. The opposite
threshold is pushed to minutes (warn-only tests) or the body waits on the
severe event (both-fire test) so warn is necessarily first. OpsEvents
recording is now lock-guarded since watchdog tasks record concurrently.

No production change. Watchdog class ran 5x green (16/16); full suite 3110/3110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:33:26 +02:00
Oliver Kieselbach cbda62d131 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-22 01:19:36 +02:00
Oliver Kieselbach 3951000b40 ci(deploy-web): skip SWA deploy when token absent instead of failing
Dependabot PRs run without repository secrets, so AZURE_SWA_DEPLOY_TOKEN is
empty and Azure/static-web-apps-deploy exits with an error, painting a red
"Build and Deploy Job" check on every web-touching Dependabot PR. Setting
skip_deploy_on_missing_secrets makes the action skip the deploy (green) when
the token is missing; real pushes to main still carry the token and deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:19:26 +02:00
dependabot[bot] c59842262e chore(deps): bump the web group across 1 directory with 10 updates (#104)
Bumps the web group with 10 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.3.11` | `3.4.3` |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.10.2` | `12.11.2` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [next](https://github.com/vercel/next.js) | `15.5.18` | `15.5.21` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.7` | `16.2.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.11` | `22.20.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.24` | `10.5.4` |
| [eslint](https://github.com/eslint/eslint) | `9.39.2` | `9.39.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `15.5.12` | `15.5.21` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@microsoft/applicationinsights-web` from 3.3.11 to 3.4.3
- [Release notes](https://github.com/microsoft/ApplicationInsights-JS/releases)
- [Changelog](https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md)
- [Commits](https://github.com/microsoft/ApplicationInsights-JS/commits)

Updates `@xyflow/react` from 12.10.2 to 12.11.2
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.2/packages/react)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `next` from 15.5.18 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.21)

Updates `@next/bundle-analyzer` from 16.2.7 to 16.2.11
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.11/packages/next-bundle-analyzer)

Updates `@types/node` from 22.19.11 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.4.24 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.24...10.5.4)

Updates `eslint` from 9.39.2 to 9.39.5
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v9.39.5)

Updates `eslint-config-next` from 15.5.12 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.21/packages/eslint-config-next)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 15.5.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: eslint
  dependency-version: 9.39.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:15:43 +02:00
dependabot[bot] c3e9621a78 chore(deps): bump the mcp group across 1 directory with 5 updates (#103)
Bumps the mcp group with 5 updates in the /src/McpServer/autopilot-monitor-mcp directory:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.28.0` | `1.29.0` |
| [fuse.js](https://github.com/krisk/Fuse) | `7.1.0` | `7.5.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.15` | `22.20.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@modelcontextprotocol/sdk` from 1.28.0 to 1.29.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.28.0...v1.29.0)

Updates `fuse.js` from 7.1.0 to 7.5.0
- [Release notes](https://github.com/krisk/Fuse/releases)
- [Changelog](https://github.com/krisk/Fuse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/krisk/Fuse/compare/v7.1.0...v7.5.0)

Updates `@types/node` from 22.19.15 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: fuse.js
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:15:10 +02:00
dependabot[bot] 3ed21c37d1 chore(deps): bump actions/upload-artifact from 6 to 7 (#102)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:08:23 +02:00
dependabot[bot] 4a1194569e chore(deps): bump actions/checkout from 5 to 7 (#91)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:07:14 +02:00
dependabot[bot] d0794fc7c5 chore(deps): bump actions/setup-node from 5 to 7 (#90)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:47 +02:00
dependabot[bot] f34c9d2322 chore(deps): bump github/codeql-action from 3 to 4 (#89)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:44 +02:00
dependabot[bot] 380a8417c8 chore(deps): bump actions/setup-dotnet from 5 to 6 (#88)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:40 +02:00
Oliver Kieselbach 63826a4869 chore(dependabot): stop auto-PRing npm major bumps
Group config already batches minor/patch; majors fall outside the group and
each opens its own PR (zod 4, typescript 7, transformers 4, msal-react 5,
react-leaflet 5, react 19, @types/node 26, eslint-config-next 16 — all
breaking). Ignore version-update:semver-major for both npm ecosystems so
majors are reviewed deliberately instead of flooding the PR list. Security
updates are exempt from this filter, so vulnerability fixes still flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:03:22 +02:00
Oliver Kieselbach 7df208fac9 fix(deps): pin patched transitive deps via overrides to clear Dependabot alerts
Force the security-patched versions of transitive dependencies without
downgrading their parents (next 15, @modelcontextprotocol/sdk 1.28), which
is what `npm audit fix` would otherwise do:

- sharp ^0.35.0   (web via next, mcp via @huggingface/transformers) — libvips CVEs
- fast-uri ^3.1.4 (mcp via ajv) — host confusion
- hono ^4.12.27   (mcp via sdk) — jsx context/XSS/header advisories
- @hono/node-server ^2.0.5 (mcp via sdk)

npm audit: 0 vulnerabilities in both projects. Builds clean; MCP 447 tests
and Web 572 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:58:37 +02:00
Oliver Kieselbach 3f9576d4a9 fix(agent): enforce the gather-rule allowlist in every collector
LogParserCollector and EventLogCollector called no guard at all, while
File/Json/Xml did — and rules/gather-rules.md already promised customers
that log-parser paths were allowlisted. A tenant admin could author a
logparser rule against C:\Users via POST /api/rules/gather and have up to
1000 lines uploaded as telemetry, bypassing the hard C:\Users block the
same page guarantees; eventlog could read any channel, Security included.
The backend validates no collectorType, target, or parameters, so the
agent is the only place this can be enforced.

Both now guard. logparser cannot check rule.Target directly (GetFullPath
throws on a wildcard), so it checks the directory for wildcard targets and
re-checks each resolved path. Event log channels get an allowlist matched
on a "/" boundary. Hard-blocked channels live in a C# constant rather than
guardrails.json: a parse error drops the allowlists to empty, which is
fail-closed, but would have lifted a block sourced from the same file.

Also fixes an inert timeout in CommandCollector: ReadToEnd() blocks until
the pipe closes, so WaitForExit(30000) was only reached once the process
had already exited and a hung command pinned its worker indefinitely.

Adds the first real CI gate. No workflow ran tests on a pull request — the
deploy workflows build without testing and build-agent.yml covered only
DecisionCore.Tests on manual dispatch — while trust/security-faq.md
already told customers automated tests run in CI. ci.yml now runs all six
suites on every PR and merge, plus a job that fails on generated-guardrail
drift, alongside CodeQL and Dependabot. OfficeBinaryWatcherTests is marked
SerialThreading; its bounded re-probe races the shared ThreadPool and
failed once under full-suite load.

Verified: 3101 backend, 1911 agent V2, 474 decision core, 44 dialog, 572
web, 447 MCP. The timeout fix is verified by runtime, not assertion — a
Start-Sleep 120 command now returns after ~30s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:28:04 +02:00
Oliver Kieselbach ee58242d33 fix(mcp): tell callers to query the English-only corpora in English
A German question returns zero results from search_docs, not poor ones: both the
documentation and the rules are English, and all-MiniLM-L6-v2 is an English-only
model, so the query vector lands nowhere near any chunk. For a German-speaking
admin driving an MCP client in their own language, the tool is effectively blind
and gives no hint why.

The caller is itself a language model, so the cheapest correct fix is to say so:
both tool descriptions now instruct it to translate the question to English before
searching and to answer the user in their own language. No infrastructure, no
model change, no ranking impact.

A multilingual embedding model would handle it natively and was considered, but it
is substantially larger — which matters on a scale-to-zero container — and it would
change English retrieval as well, so it would have to be measured against
scripts/eval-docs-search.ts rather than swapped in on the assumption that more
languages cannot hurt.

Verified: 447 tests pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:42:56 +02:00
Oliver Kieselbach eca528b9b3 docs(mcp): attribute the cold start — the app is 0.5s of it, the platform is the rest
The boot marks answered the question on the first production cold start, and the
answer overturns the reasoning behind an infrastructure change made earlier today.

  +244ms  node started (startup + module loading)
  +431ms  corpora read from disk          (+187ms)
  +491ms  index read, validated, hydrated  (+60ms for 3.12 MB)
  +504ms  listening
  +677ms  query embedder warm             (background)

Against a 31.5s cold start, the application accounts for half a second. The other
~98% is Container Apps activation: KEDA reacting, pod scheduling, mounting the
236 MB image, starting the container. The container also boots faster than a
development laptop (504ms vs 1273ms), so nothing here is CPU-starved.

Consequences recorded rather than quietly dropped:

- Raising the container to 0.5 vCPU was justified by the embedding model load on
  the cold-start path. Prebaked, that load is 173ms and runs in the background
  where only the first search could wait on it. The justification does not survive
  measurement. The setting stays — it plausibly helps concurrent requests, and at
  scale-to-zero the cost difference is negligible — but the Bicep comment now says
  what is true instead of what was assumed, so nobody re-derives the wrong reason.
- Index size is irrelevant to startup (60ms for 3.12 MB), so the corpus can grow
  freely, and the embedding rounding earns its keep on file size alone.
- The only lever that would actually remove the cold start is minReplicas: 1, at
  roughly 7x the monthly free consumption grant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:34:24 +02:00
Oliver Kieselbach 8305f5c850 feat(mcp): time the boot so the cold start can be attributed
A cold start after scale-to-zero measures 24.5s to first byte against 60ms warm,
and nothing outside the process could say why. Log Analytics stamps every console
line of one ingestion batch with the same time, so the boot sequence collapses to
a single instant however it is queried, and piping stdout locally buffers until
exit. So the server times itself: `[boot +Nms]` marks derived from
process.uptime(), which unlike an in-process timer also covers Node's own startup
and module loading.

Measuring the precomputed path locally already contradicts two assumptions worth
recording:

  +860ms   node started (startup + module loading)
  +1199ms  corpora read from disk         (+339ms)
  +1242ms  index read, validated, hydrated (+43ms for 3.12 MB)
  +1273ms  listening                       (+31ms)
  +1497ms  query embedder warm             (+224ms, background)

The index is 43ms, not seconds — its size is not the cold-start cost, and growing
the corpus is cheap. The prebaked embedder is 224ms and loads in the background,
so only the first search could ever wait on it. That was the stated reason for
raising the container to 0.5 vCPU, which now looks like a weaker justification
than it did; the production marks will settle it. If they show the app listening
after a few seconds, the rest of the 24.5s is platform activation that neither CPU
nor index changes can touch.

Verified: 447 tests pass, marks appear on both boot paths locally. Incidentally
the run also confirmed the corpus hash guard and the staleness signal both work —
the local docs repo has moved to 6333d56 while the deployed image reports d8cb7f4,
so the precomputed index was correctly rejected and recomputed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:14:49 +02:00
Oliver Kieselbach c2c02b929c fix(web): show gather-rule scope/emit controls only where they change behavior
The form offered trigger, phase scope and emit mode unconditionally, which
produced combinations that cannot do anything plus one silently wrong state:

- A scope on a Phase Start / Phase End rule that names a concrete phase can only
  ever suppress the single firing — the phase IS the moment. Same for emit mode:
  a one-shot has no second result to dedup against.
- Selecting "From a phase onwards" (or "Only during specific phases") and not
  filling the detail dropdown serialised to null, i.e. the rule ran everywhere
  while the form still read "From a phase onwards".

supportsPhaseScope / supportsEmitMode now drive visibility; buildScopeFields
nulls hidden-control state so a value from an earlier trigger choice cannot leak
into the payload; validateScopeSelection rejects an unfilled scope mode on both
create and save. Startup is the deliberate exception — a scope there defers the
one-shot instead of suppressing it, so the scope control stays and only emit mode
is hidden. One-shot triggers now state in-form when they collect.

Sub-dropdown hints no longer claim "No selection = all phases" (they turn amber
and name the required action instead).

Tests: app/gather-rules/__tests__/gatherRuleFormLogic.test.ts (15) pins the
applicability matrix, the payload nulling incl. the trigger-switch leak, the
unfilled-scope rejection, and the JSON-mode scopeMode derivation.
vitest 572/572; tsc silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:06:50 +02:00
Oliver Kieselbach 1821af6f05 fix(mcp): fire the search_docs keyword fallback on a weak semantic head
The live test found a third recall gap. The fallback only topped up when semantic
search returned FEWER than topK results — but the more common failure is a full
list of unconvincing ones. Querying "WOW6432Node" returned three chunks scoring
~0.29, none containing the word, while the single chunk that does was never
considered at all.

The fix is a ranking change, so it was measured rather than reasoned about. Adds
scripts/eval-docs-search.ts, which scores strategies over 2465 queries in three
deliberately different classes: every chunk heading (easy by construction, so a
regression detector), rare identifiers (the class this fallback serves), and
hand-written questions (the reality anchor — not derived from the corpus, so it
shares little vocabulary with it). Embeddings are computed once and each strategy
is a pure function over the same candidates, so sweeping a threshold costs no
extra inference.

                        known-item  literal  handwritten MRR  fixed  broken
  append-only (before)       71.9%    66.2%            1.000      -       -
  weak < 0.30                71.9%    72.6%            1.000    142       0
  weak < 0.35                73.7%    81.1%            1.000    335       0
  weak < 0.40                76.3%    88.2%            0.944    502       3
  weak < 0.45                77.2%    93.0%            0.889    613       5

0.35 is strictly dominant — better in every class, worse in none — so that is
what ships. Higher thresholds keep repairing identifier queries but start
demoting hand-written questions, the class that most resembles real use, so the
gain stops being free. When the head is weak, exact matches move to the front
while at least one semantic result is kept, so a weak-but-correct hit is never
pushed out entirely.

The measurement also resized the problem: identifier queries were succeeding only
66.2% of the time, so the reported case was one of roughly 750, not an outlier.
That is the part a single anecdote could not have shown, and the reason the
harness exists before the fix rather than after it.

Verified: 447 tests pass, including new cases for the weak path and for leaving a
confident ranking untouched. On the real corpus "WOW6432Node" now returns the
chunk that contains it at rank 1, while "how do I deploy the agent with Intune"
(0.819) and "what firewall URLs do I need to allow" (0.374) are unchanged and the
negative controls stay empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:39:26 +02:00
Oliver Kieselbach 62599d972c fix(mcp): put every doc token back into search_docs ranking
Two measured recall gaps in search_docs, both silent.

1. Truncation. MiniLM's context is 256 tokens; 87 of 324 chunks exceeded it and
13% of all tokens never reached the ranking — the tokenizer drops the tail
without complaint. Concretely, in built-in-rules.md :: Apps (734 tokens) the
strings ANALYZE-APP-013, ANALYZE-OFFICE-001, ANALYZE-CORR-003 and Click-to-Run
all sit past the cutoff, so "proxy configuration blocking app downloads" did not
retrieve that section at all.

Shrinking chunks cannot fix this: the corpus ranges 2.43 to 5.81 characters per
token, so no character budget bounds tokens, and a budget safe for the worst case
(~620 chars) would shred prose and tables alike. So the chunk stays whole and the
embedding is split instead — index() cuts long text into 250-token windows with
40 overlap, a document carries embeddings[][], and search() scores it by its BEST
window. Max rather than mean: averaging would blur a long section's several
topics into one mediocre direction, the same dilution that makes large context
windows worse than chunking.

The tokenizer is a build-time dependency only (vocabulary, not ONNX weights,
~93 ms); production boots from precomputed vectors and never loads it.

2. No general fallback. Proper nouns embed poorly — "RealmJoin" occurs in 3
chunks yet scores under the threshold. Fuzzy matching was built, measured and
rejected: at a threshold low enough to surface RealmJoin (0.28), Fuse also
returns 5 hits for "SCEPman", a word this corpus contains zero times. Literal
containment has neither problem, and every provider already exposes it. So a
short result set is now topped up via lexicalMatch on distinctive query terms,
requiring two matching terms as corroboration — without that rule "how do I bake
sourdough bread" matched a page about LLM providers, because "bake" is a
substring of "baked into the image". Keyword hits are appended and flagged, never
sorted together with cosine scores.

Also sharpens the tool description with the measured reason to prefer this over
fetching the docs site (~1k tokens for topK=3 against ~9k raw / ~600k rendered,
version-matched, and matching on meaning), and replaces the growing tail of
positional docs parameters with a DocsSearchBundle.

Verified: token coverage 87% -> 100%; "Office Click-to-Run install never
finished" 0.318 -> 0.442; "proxy configuration blocking app downloads" absent ->
0.559 at rank 1; the ten reference queries keep byte-identical scores and rank 1
(short chunks are one window, so the same vector as before — no long-chunk bias).
The fallback is not a dead path: across 904 rare corpus tokens queried alone it
contributes hits for 431, while negative controls stay empty. Precompute output
is accepted by the boot validator and a mutated corpus still rejected; index
2.63 -> 3.12 MB for 324 chunks / 424 vectors. The rules corpus gains the same
treatment unplanned: 128 rules now carry 172 vectors. 445 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:17:42 +02:00
Oliver Kieselbach 21b751be64 changed sidebar tenant settings entries ordering 2026-07-21 20:02:28 +02:00
Oliver Kieselbach 63ea2e3cee feat(mcp): search_docs — customer documentation as a third search corpus
The MCP server could answer telemetry questions ("why did this session fail")
but not product ones ("how do I deploy the agent", "where is my data stored").
Those answers already exist in the autopilotmonitor-docs bundle; search_docs
makes them semantically searchable.

Deliberately a separate index from search_knowledge: rules and prose answer
different questions, and sharing one topK would dilute both. Registered for
every role — published documentation carries no tenant data — and skipped
entirely when no corpus was baked in, so a doc-less build advertises no
broken tool.

Corpus handling:
- Chunked at h2/h3 and <details>/<summary>, each chunk prefixed with a
  "Page > Section > Subsection" breadcrumb so a mid-page hit is self-describing.
- Newlines are normalized before any parsing. This is a correctness
  precondition, not cosmetics: the bundle ships CRLF and JS `.` does not match
  \r, so /^(#{2,3})\s+(.*)$/ matched zero headings — silently, degrading every
  page to unlabelled paragraph splits.
- Two size exceptions: tables are never split (a split table leaves `| --- |`
  rows with no header), and unbroken changelog lists split at line level (left
  whole, their tails fall outside the model window and become unsearchable).
- Short sections are kept — a one-line answer is often the best hit, and the
  breadcrumb supplies the context a short body lacks.

Build and delivery:
- Docs are embedded at image build time into an optional third
  PrecomputedSection. Optional so a checkout without the docs repo does not
  invalidate the rules and event-type vectors; the reverse (server has docs,
  file does not) is staleness and is rejected.
- The deploy workflow checks the public docs repo into the build context and
  passes its SHA as DOCS_COMMIT, which /health reports alongside the chunk
  count — editing the docs does not update a running server, so staleness is
  made checkable rather than memorable.
- Embeddings are rounded to 6 decimals on serialize: 55% smaller index (2.63
  vs 5.88 MB) for a ~1e-5 norm error against a 1e-3 tolerance. indexPrecomputed
  now asserts unit norm, since the hydrate path never checked it.

Infra: the Bicep template was two changes behind the live deployment (EU
cutover and ACR hardening, both applied directly in Azure). Reconciled against
the running resources — managed-identity image pull replaces the admin
credentials and the acr-password secret — and infra/ is no longer gitignored so
it stops drifting. Container raised to 0.5 vCPU / 1.0 GiB for the cold-start
query-embedder load. The template does not model the mcp.autopilotmonitor.com
custom domain, so a full deployment would remove it; the header warns about it.

Verified: 428 tests pass; 45 pages produce 324 chunks; sample queries hit the
right page top-1/2; all 8 derived URLs return 200 live; the precompute output
is accepted by the boot validator and a mutated corpus is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:48:28 +02:00
Oliver Kieselbach 93dcac44a4 fix(web): add Contact to the tenant settings sidebar
The section was registered for routing but not in the sidebar, so it was
reachable only by typing the URL. Sidebar entries come from
globalNavConfig.tsx, which is not derived from tenantNavSections.ts -
both have to be touched for a section to exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:19:44 +02:00
Oliver Kieselbach 72be8ac89f feat(config): tenant contact address, seeded once from the preview email
Enforcement and service notices need somewhere to go. Until now the only
address on file was the Private Preview notification email, which is
onboarding data in the PreviewWhitelist table and not something a tenant
can maintain.

Adds TenantConfiguration.ContactEmail, owned and edited by the tenant's
own admins under Settings -> Tenant -> Contact:

- Seeded once when a preview notification address is saved, and only
  while ContactEmail is still empty. It is never re-synced, so an edit in
  the portal cannot be overwritten later by the onboarding source.
- Backfilled for tenants onboarded before the field existed, as a
  manual-trigger maintenance task alongside the other backfills. It skips
  any tenant that already has an address, so it converges to a no-op and
  can be dropped once everyone is covered.
- Surfaced read-only in Admin -> Tenants next to the preview address, so
  an operator who needs to warn a tenant does not have to read the table.

Purpose is stated where it is entered and in the Privacy Policy: service
matters only - a technical problem, a security matter, or a change
needing an administrator's attention. Never marketing, never shared.

The Terms already say a warning is sent "where a tenant contact address
is on file"; this is the field that makes that promise meaningful.

Wired through the full tenant-config round-trip (types, context state,
hydrate, save payload, reset, provider) so a save from any other section
cannot clobber it.

Verified: solution builds clean, 3101 backend tests and 557 web tests
pass, tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:00:18 +02:00
Oliver Kieselbach cd570c8b3e feat(web): add IP/licence terms, enforcement ladder, security reporting
- Intellectual Property & Licensing section: the repository licence
  governs the code, not the hosted service, its infrastructure, its data,
  or the name and branding. Customer data stays with the customer.
- Acceptable Use now states the enforcement ladder (warning, reduced rate
  limits, suspension, termination) instead of an unqualified right to
  suspend, and routes security research to GitHub Security Advisories
  with explicit testing boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:38:52 +02:00
Oliver Kieselbach 6dfc564be6 fix(web): align privacy and terms with the DPA-owns-the-register split
Privacy: "Sub-processors and Outbound Data Flows" becomes "External
Services". It still names who receives data, which is a controller's own
duty, but drops the Art. 28 vocabulary and the promise to announce new
sub-processors - that commitment lives in the data processing agreement.
Points at the renamed docs page and notes the DPA is available on
request.

Terms: the MSP clause now says a managing organization is responsible for
having the necessary data protection agreements with its customers,
instead of classifying it as a processor or sub-processor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:25:36 +02:00
Oliver Kieselbach afecd18123 fix(web): patch js-yaml and brace-expansion DoS advisories
Dependabot alerts #63/#64/#65 (all high): brace-expansion exponential-time
expansion of consecutive non-expanding {} groups (GHSA-3jxr-9vmj-r5cp,
both the 1.x and 2.x trees) and js-yaml quadratic CPU consumption via
YAML merge-key chains (GHSA-52cp-r559-cp3m).

All three are transitive dev-only dependencies of the eslint toolchain
(eslint -> @eslint/eslintrc -> js-yaml; eslint/typescript-estree ->
minimatch -> brace-expansion) and resolve within their existing semver
ranges, so only the lockfile changes.

npx tsc --noEmit clean, vitest 557 passed, npm audit reports 0
vulnerabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:53:28 +02:00
Oliver Kieselbach 5a294ac4f6 fix(mcp): patch transitive protobufjs/tar/body-parser advisories
Dependabot alert #62 (protobufjs 7.6.4, DoS via infinite loop in .proto
option parsing, GHSA fixed in 7.6.5) plus the tar (critical) and
body-parser (low) advisories npm audit surfaced alongside it. All three
are transitive under @huggingface/transformers and resolve within their
existing semver ranges, so only the lockfile changes.

Lockfile also picks up the pending 1.5.0 -> 1.5.1 package version sync.

npx tsc --noEmit clean, vitest 400 passed / 41 skipped, npm audit
reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:50:51 +02:00
Oliver Kieselbach dbf6e50e2d feat(agent,web,shared): phase_exit gather trigger — one-shot collection at a phase's end
Follow-up to gather-rule phase scoping. One-shot collection at a phase START
already existed (phase_change dedups per (rule, phase)) and at enrollment END
(on_event + enrollment_complete); the END of a specific phase had no trigger.

New trigger value "phase_exit": fires once when TriggerPhase is LEFT (empty =
every phase transition). Together with phase_change it forms the two one-shot
bookends of a phase.

Agent (GatherRuleExecutor.OnPhaseChanged):
- Exit rules are evaluated BEFORE _currentPhase advances, so both the
  TriggerPhase match and the phase-scope gate see the phase being LEFT. A rule
  scoped to DeviceSetup therefore fires on its own exit even though the phase
  being entered is out of scope.
- A transition into Failed fires the exit rules of the failing phase — the
  snapshot at a failure boundary is the point of the trigger.
- Exit of Unknown never fires; a repeated identical phase signal is not a
  transition.
- Dedup key "exit:{ruleId}|{phase}" shares _phaseRulesExecuted with the
  phase_change keys but stays in a disjoint key space, so an enter-rule and an
  exit-rule on the same phase cannot consume each other's slot.

Portal: trigger labels are now "Phase Start" / "Phase End"; the canonical phase
dropdown and the create/save payloads cover both via PHASE_TRIGGERS; the card
renders "entering X" / "leaving X".

No ConfigVersion bump: no new field is transported. Old agents do not know the
value and simply never fire the rule (silent no-op).

Tests: GatherRuleExecutorPhaseExitTests (11) — enter-vs-exit, no refire on
re-entry, empty-triggerPhase, exit-of-Unknown, same-phase-signal, into-Failed,
Complete, scope-against-left-phase, out-of-scope slot preservation, key-space
disjointness, on_change interplay. Build 0 Errors; V2.Core.Tests 1884/1884;
Functions.Tests 3099/3099; vitest 557/557; tsc silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:37:48 +02:00
Oliver Kieselbach 062962ae41 docs(claude): dedupe and condense agent directives
CLAUDE.md ships with every request. Removed the example from the
operator-only-infrastructure rule - rules state the rule, not an
instance - and applied the same yardstick to the rest of the file.

Merged what said the same thing twice: "would a staff engineer approve"
and "what would a perfectionist reviewer reject"; Demand Elegance and
Code Quality and Core Principles; Task Management's lessons item and the
Self-Improvement Loop; Task Management's plan steps and Plan Mode
Default. Dropped motivational framing that steers no decision, and a
stray heading level.

Every actual rule survives: the 3+ step plan threshold, stop-and-re-plan,
subagent usage, verify-before-done with a diff against main, autonomous
bug fixing, lessons capture, 500 LOC chunked reads, no absence claims
from one grep, the structural-fix mandate, and the Codex review note.

425 words, down from 614 at the start of the session - and that now
includes the customer-facing claims section, which did not exist then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:15:14 +02:00
Oliver Kieselbach be08660976 docs(claude): condense customer-facing claims and OKF sections
CLAUDE.md ships with every request, so length is a running cost. Cut the
new claims section from 12 lines to 4 and tightened the OKF section,
dropping the spec URL and restating the rules imperatively. Net effect:
the file is now smaller than before the claims section was added
(654 words vs 856).

No rules were removed - verify-before-writing, the change triggers, the
durable-phrasing rule, and the operator-only-infra caveat all remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:48 +02:00
Oliver Kieselbach b032005f5b docs(claude): add customer-facing claims to the release checklist
Terms, Privacy, the About page and the trust docs make statements that
customers and security reviewers rely on, but nothing opened them during
a refactor, so they drifted. The July 2026 sweep found a ".NET 8" backend
that was .NET 10, a "Next.js 18" that does not exist, ETL log collection
that never happened, and "each tenant runs its own isolated instance" for
a shared multi-tenant service.

Records which surfaces exist, which kinds of change must update them in
the same commit, and that claims are verified against the code rather
than carried forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:01:23 +02:00
Oliver Kieselbach 0ab568f479 fix(web): correct stale About claims; open Community plan to any tenant
Terms: replace the blanket business-use-only clause. It excluded exactly
the wrong group - a registered association is not a consumer under German
law anyway (only natural persons can be), while the clause did shut out
private individuals running their own lab or personal tenant, who are a
real and intended audience for a free community edition. Community is now
stated as open to anyone with an eligible Entra ID/Intune tenant;
business-only stays where it means something, on the Enterprise contract.

About page - claims verified against the code and corrected:
- "each customer tenant runs its own isolated instance" was wrong and
  contradicted the same page two sections earlier. It is one shared
  multi-tenant service with partition-key isolation and delegated MSP
  access.
- Diagnostics: no ETL tracing and no Windows event log export happens;
  the bundle is agent logs, IME logs, agent state and device info. Upload
  is tenant-configured (off/always/on failure) at the end of enrollment,
  not triggered on demand from the portal - the portal downloads it.
- .NET 8 Isolated -> .NET 10 Isolated on Flex Consumption.
- "Next.js 18" -> Next.js 15 (React 18); 18 was React's version.
- Roles: Admin/Operator -> Admin/Operator/Viewer plus delegated MSP.
- Notifications: Teams only -> Teams, Slack, generic JSON webhooks, with
  start/success/failure and SLA triggers. Telegram stays unlisted; it is
  operator-only alerting, not a tenant feature.
- Agent deployment: Win32 app -> Intune platform script; self-destruct is
  on by default, not optional-sounding.
- Dropped the "tamper-evident" audit claim - records are ordinary table
  entities with no hash chain, signing, or immutability policy.

Confirmed while verifying: no tenant-configurable email channel exists
anywhere in the codebase, so the published privacy statement that Resend
serves only the onboarding mail is accurate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:58:07 +02:00
Oliver Kieselbach fced858a7a feat(legal,agent): professionalize Terms/Privacy; wire SendTraceEvents gate
Rework the public Terms of Use and Privacy Policy from their early-days
wording to match how the service actually runs today, and fix a setting
that looked like a privacy control but gated nothing.

Legal positioning:
- glueckkanja AG operates the service and is the counterparty on BOTH
  plans (Community/Enterprise is a flag in one system, one subscription).
  Oliver Kieselbach is named as creator and maintainer of the open-source
  project and the Community edition, acting on behalf of glueckkanja AG
  and explicitly not a separate contracting party.
- Community: free, "AS-IS", no availability or support commitment.
  Enterprise: written agreement incl. DPA, higher limits, MSP delegation.
- Added business-use-only clause, simplified governing law (dropped the
  CISG exclusion - it governs sale of goods, not SaaS), split liability
  per plan, added an MSP delegation section and a rules-contribution
  clause covering the community rule pool.
- Imprint links to the glueckkanja AG imprint, whose scope clause covers
  every service linking to it - no duplicated company data to go stale.
  Reachable from the landing footer and both help menus.

Privacy Policy corrections (previous claims were inaccurate):
- "No personal data is transmitted" - backend telemetry carries the UPN;
  only the browser SDK is cookie- and UPN-free.
- "Not shared with third parties" - qualified for MSP delegation,
  customer-configured notification channels, the customer's own LLM via
  MCP, and agent-side geolocation.
- Removed "data subject rights are not guaranteed"; GDPR rights are not
  optional. Added region, sub-processors, retention and IP handling.

Agent - SendTraceEvents gate:
The setting was plumbed from TenantConfiguration through to
AgentConfiguration but never consumed; Trace severity only hides an event
from the timeline view. The outbound IP was therefore always collected
when geolocation is on (which is the default). Gate is now enforced in
TelemetryEventEmitter.Emit, the single choke point before the transport
enqueue. Dropped before the sequence counter advances so suppression
leaves no gaps. Evaluated per event via Func<bool> so a mid-session
remote-config merge takes effect. Fails open: an unwired or throwing
accessor lets trace events flow, because the gate suppresses diagnostics
and must not silently blind the timeline.

Default stays on and the switch remains operator-only via the config
table - deliberately not surfaced in the UI or documentation.

Verified: solution builds clean, 1873 agent core tests pass,
tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:42:03 +02:00
Oliver Kieselbach ee947e2ca9 updated docs links 2026-07-20 20:52:56 +02:00
Oliver Kieselbach 5f08a6ec68 updated dev script 2026-07-20 19:52:54 +02:00
Oliver Kieselbach a97888ebd5 fix(agent): neutral aggregated 2800 observations; reboot claim gated on observed reboot
Rework after the first real enrollment (session b2e890c1) exposed three
defects in a654c730:

- Shell-Core "RebootCoalescing" match was a false positive by construction:
  the token appears in the ROUTINE SubcategoryProcessing_Started bootstrap
  marker ("Starting subcategory DeviceSetup.RebootCoalescing...") on EVERY
  enrollment. esp_reboot_coalescing removed entirely (branch, constant,
  catalog, tests).
- 2800 reboot-required flags fire at every policy sync; only DeviceSetup-time
  flags cause the coalesced reboot. The session's flags fired during
  AccountSetup - no reboot - yet our Warning events claimed "device will
  restart". Raw event is now a neutral Info observation; the reboot CLAIM
  moved to ANALYZE-ESP-005 with a REQUIRED system_reboot_detected >= 1
  condition (no observed reboot -> advisory silent).
- One event per 2800 record was spam: records are now buffered and debounced
  (10 s) into ONE aggregated event per burst (rebootUris[]/uriCount/
  firstRebootUri/recordCount/unparsedCount). Watermark is persisted at FLUSH,
  not claim - a pre-emit reboot kill loses nothing, the post-restart backfill
  re-emits the burst at its historical time.

Verified 2800 description shape recorded in code, tests, and concept doc:
"The following URI has triggered a reboot: (./Device/...)."; extraction
covered by a real-text regression test. Lesson added to tasks/lessons.md
(observation-level events vs outcome-gated claims; substring tokens appear
in framework bookkeeping records).

Tests: Agent.V2.Core 1867/1867, Functions 3099/3099, MCP 400 passed,
solution build 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:41:47 +02:00
Oliver Kieselbach a654c7309e feat(agent): attribute mid-ESP coalesced reboots to MDM policy URIs (second sign-in detection)
Device-assigned MDM policies whose CSP URIs match the OS reboot-required
catalog force a coalesced reboot at the end of ESP DeviceSetup - the user
sees an unexpected restart and a second sign-in before AccountSetup.
Invisible in the Intune console; admins simply lived with it. Now attributed
and actionable (PMPC research).

- MdmRebootPolicyTracker (+host): watches DeviceManagement-Enterprise-
  Diagnostics-Provider/Admin EventID 2800, emits one mdm_policy_reboot_required
  per reboot-forcing URI (Warning, ImmediateUpload; tolerant EventData-first
  URI extraction with regex fallback; fail-soft OMADM RebootRequired probe).
  Cross-restart RecordId watermark (mdm-reboot-watermark.json) - mandatory,
  the coalesced reboot restarts the agent and the backfill re-reads the log.
- ShellCoreTracker: RebootCoalescing branch on the already-watched 62407
  stream -> esp_reboot_coalescing corroboration event; deliberate deviation:
  emits on backfill too (live emit races the reboot kill).
- ANALYZE-ESP-005 advisory (warning): names the first URI via {{rebootUri}},
  remediation = reassign profiles device -> user groups; +20 confidence on
  coalescing corroboration, +10 on >=2 URIs.
- Config: Collectors.MdmRebootPolicyWatcherEnabled (default ON, internal
  steering only - agent-native collector, NOT an IAgentAnalyzer, no admin UI),
  MdmRebootPolicyBackfillLookbackMinutes=60; ConfigVersion 35.
- Event types deliberately distinct from system_reboot_detected so the
  backend RebootCount (exact-string match, 2 sites) stays uninflated.
- Docs: docs/agent/mdm-reboot-coalescing.md (+index/log). Exact 2800/62407
  message texts are unverified until E2E VM validation (tolerant matching).

Tests: Agent.V2.Core 1868/1868 (21 new), Functions 3099/3099, MCP 400 passed
(event-types drift), solution build 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:05:11 +02:00
Oliver Kieselbach 6d1ff8d7f0 fix: code-review follow-ups (watcher dispose race, revoke-enforcement tests, scan guard)
- ConsoleBypassWatcher: re-check _disposed after watcher.Start() — a Dispose()
  racing the background arm could leave an armed WMI subscription no code path
  could ever stop (leaked until process exit).
- SignalRContext: collapse rapid accessRevoked pushes into one restart via an
  in-flight guard — a second start() on a non-Disconnected connection throws
  and burns the retry budget.
- config/all: negative-cache managed ids a rescue scan proved missing (5 min
  TTL) — one stale grant (offboarded tenant) no longer makes every delegated
  config/all call pay a full table scan.
- DelegatedAdmin/TenantGroup functions: extract testable revoke-enforcement
  cores (RevokeCoreAsync, SetEnabledCoreAsync, UnassignCoreAsync,
  DeleteGroupCoreAsync, ValidateGrantRequest) and cover the security flows:
  404-on-missing-row writes no audit row and fires no disconnect; real
  revoke/disable/unassign/group-delete always cuts live streams; grant
  validation is fail-closed (GUID tenantId, recognized roles only).
- dashboard/page.tsx: drop unused next/link import left by the banner removal.

Tests: backend 3099 passed, web 557 passed, agent ConsoleBypassWatcher suite
passed, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:13:26 +02:00
Oliver Kieselbach 5c8b273f37 chore: bump skills submodule (system-Timestamp warning in raw-query skills)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 12:38:04 +02:00
Oliver Kieselbach da958b5398 fix(backend): business timestamps survive storage migrations (OccurredUtc + RowKey decode)
The 2026-07-18 storage migration reset every row's system Timestamp,
exposing that AuditLogs, OpsEvents and Events displayed, filtered and
retention-swept on it (custom "Timestamp" writes were silent no-ops —
reserved system property). All migrated rows read as 2026-07-18.

- New Helpers/BusinessTimestamp: RowKey decoders (audit !revtick_guid,
  ops bare revtick, event yyyyMMddHHmmssfff_seq), RowKey-range OData
  clause builders (tick-exact, with the mandatory RowKey lt '"' guard
  so no lower bound can ever match legacy bare-GUID audit rows), and
  dual DateTimeOffset/DateTime column reads.
- Writes in all three tables now persist OccurredUtc (custom column,
  copied verbatim by future migrations); no-op ["Timestamp"] removed.
- Read mappers resolve OccurredUtc -> RowKey decode -> system Timestamp;
  GetEarliestSessionEventTimestampAsync uses the same resolution so
  StartedAt/Duration reconciliation can no longer absorb the migration
  date on migrated sessions.
- Date-window filters and retention moved off the system Timestamp onto
  RowKey ranges; ops retention loses its full-table scan and the
  post-migration mass-delete cliff; audit retention runs a temporary
  second pass for legacy GUID rows (TODO: remove after 2027-03-01).
- New GlobalAdmin backfill endpoint POST /api/maintenance/
  backfill-occurred-utc (dry-run by default, batched, idempotent MERGE
  of only OccurredUtc, table=audit|ops) — Events rows are deliberately
  never mutated; their read path decodes the RowKey instead.
- Tests: decoders, clause boundary proofs incl. GUID-safety pins,
  mapper precedence, backfill row decisions (Functions.Tests 3078).
- Docs: docs/backend/business-timestamps.md (system Timestamp is never
  authoritative; migration checklist rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:29:53 +02:00
973 changed files with 70844 additions and 10848 deletions
+25 -67
View File
@@ -1,79 +1,37 @@
## Workflow Orchestration
# Agent Directives: Mechanical Overrides
# Agent Directives
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
Codex reviews your output once you are done.
## Code Quality
### 1. Plan Mode Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
- Use plan mode for specification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One tack per subagent for focused execution
### 3. Self-Improvement Loop
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes — don't over-engineer
- Challenge your own work before presenting it
### 6. Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests — then resolve them
- Zero context switching required from the user
- Go fix failing CI tests without being told how
### Tools Execution
- Read any file over 500 LOC in chunks using offset and limit parameters.
- Do not assume a single grep caught everything.
### Code Quality
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
Codex will review your output once you are done!
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
## Task Management
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to `tasks/todo.md`
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
## Core Principles
## Customer-Facing Claims
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
## Technical Docs — OKF Knowledge Bundle (`docs/`)
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
`docs/` is an [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) knowledge bundle for contributor/AI-facing technical documentation (customer docs live in the separate autopilotmonitor-docs repo).
## Technical Docs — OKF Bundle (`docs/`)
- **Consume first**: start at `docs/index.md` for progressive disclosure before opening individual documents.
- **When new durable technical knowledge emerges** (architecture decisions, flows, non-obvious mechanisms), capture it as an OKF concept document in `docs/` — markdown with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp` (recommended).
- **Maintain the bundle**: add every new document to `docs/index.md`, note changes in `docs/log.md` (grouped by ISO date). Use standard RELATIVE markdown links between docs — never the OKF `/`-prefixed bundle-absolute form (GitHub resolves those from the repo root and navigation breaks). `index.md` and `log.md` are reserved filenames.
- Documents are English, structural markdown over prose (`# Schema`, `# Examples`, `# Citations` sections where applicable).
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
+31
View File
@@ -0,0 +1,31 @@
# CodeQL configuration.
#
# The default query suites stay active — this file only filters out two C# queries
# that fire structurally on this codebase, where the flagged behavior is the feature,
# not a defect:
#
# cs/exposure-of-sensitive-information
# Audit and operational logs record WHO performed an action (actor UPN / email)
# and notification services deliver mail to a recipient address. Writing that
# identity to the log / notification IS the purpose of the field. Every such site
# is a legitimate, reviewed use — the query cannot distinguish it from a leak.
#
# cs/cleartext-storage-of-sensitive-information
# The rate limiters and distress guard use the trusted client IP as their bucket
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
# the in-memory / table rate-limit key is by design.
#
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
# dismissed individually in the Security tab so those queries stay sharp — they are
# NOT suppressed here.
#
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
# catching a future unsanitized sink.
query-filters:
- exclude:
id: cs/exposure-of-sensitive-information
- exclude:
id: cs/cleartext-storage-of-sensitive-information
+47
View File
@@ -0,0 +1,47 @@
version: 2
updates:
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
dotnet:
patterns: ['*']
update-types: [minor, patch]
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
web:
patterns: ['*']
update-types: [minor, patch]
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/McpServer/autopilot-monitor-mcp
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
mcp:
patterns: ['*']
update-types: [minor, patch]
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
# Security updates ignore this filter, so the Security tab stays clean.
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Reserves a monotone build number from a counter blob (ETag compare-and-swap).
.DESCRIPTION
Single source of build numbers for every component that ships from this repo
(agent, backend, MCP). Each component owns one counter blob holding a plain
integer; this script reads it, writes value+1 back under If-Match, and returns
the reserved number.
Reserve-BEFORE-build: two concurrent builds can never mint the same number.
A failed build burns a number, which is harmless -- uniqueness matters,
density does not.
The blob is public-read; only the write needs the container SAS.
Why a counter and not the published version manifest: manifests are written
on publish, not on build (the agent writes version.json only for stable
releases), so two dev builds in a row would derive the same number and
overwrite each other's versioned artifact.
.PARAMETER CounterUrl
Full URL of the counter blob, without SAS.
.PARAMETER SasToken
Container SAS with create+write permission. A leading '?' is tolerated.
.PARAMETER Override
Explicit build number instead of counter+1. If it is not greater than the
current counter the blob is left untouched (re-build of a number that was
already reserved). 0 = no override.
.PARAMETER MaxAttempts
CAS retries before giving up.
.OUTPUTS
[int] the reserved build number. Progress goes to the host, not the pipeline.
.EXAMPLE
$n = ./Request-BuildNumber.ps1 -CounterUrl $url -SasToken $sas
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$CounterUrl,
[Parameter(Mandatory = $true)][string]$SasToken,
[int]$Override = 0,
[int]$MaxAttempts = 5
)
$ErrorActionPreference = 'Stop'
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...").
$writeUrl = $CounterUrl + '?' + $SasToken.TrimStart('?')
function Get-HttpStatus {
param($ErrorRecord)
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
return [int]$ErrorRecord.Exception.Response.StatusCode
}
return 0
}
function Read-Counter {
try {
$resp = Invoke-WebRequest -Uri $CounterUrl -Method Get -UseBasicParsing
} catch {
if ((Get-HttpStatus $_) -eq 404) {
throw "Counter blob not found at $CounterUrl. Seed it once with the last used build number: PUT the plain integer with 'x-ms-blob-type: BlockBlob' and 'If-None-Match: *'. See docs/versioning.md."
}
throw
}
$raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
# If-Match requires the quoted ETag form; PowerShell strips the quotes on read.
$etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
return [pscustomobject]@{ Value = [int]$raw.Trim(); ETag = $etag }
}
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$counter = Read-Counter
if ($Override -gt 0 -and $Override -le $counter.Value) {
Write-Host " Build number $Override <= counter $($counter.Value) -- already reserved, counter blob unchanged"
return $Override
}
$target = if ($Override -gt 0) { $Override } else { $counter.Value + 1 }
try {
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
Invoke-RestMethod -Uri $writeUrl -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
Write-Host " Reserved build number $target (CAS write ok, attempt $attempt)"
return $target
} catch {
if ((Get-HttpStatus $_) -eq 412) {
Write-Host " CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
Start-Sleep -Seconds ($attempt * 2)
} else {
throw
}
}
}
throw "Could not reserve a build number after $MaxAttempts CAS attempts"
+32 -67
View File
@@ -56,78 +56,27 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v7
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
# Build numbers come from the shared counter blob (single source of truth for CI
# AND local build.ps1). Reserve-before-build with ETag-CAS: concurrent builds can
# never mint the same number; a failed build burns a number, which is harmless.
# The blob is public-read (like the rest of the container); writes need the SAS.
# Build numbers come from the shared counter blob -- one counter and one
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
# number; a failed build burns a number, which is harmless.
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
run: |
$counterBlob = "buildcounter-v$env:LINE_MAJOR.txt"
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/$counterBlob"
# Tolerate both secret formats ("sp=cw&..." and "?sp=cw&...") -- the URL below adds the '?'.
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
$override = "${{ inputs.build_number }}"
$override = 0
if ('${{ inputs.build_number }}' -ne '') { $override = [int]'${{ inputs.build_number }}' }
function Get-Counter {
try {
$resp = Invoke-WebRequest -Uri $counterUrl -Method Get
$raw = if ($resp.Content -is [byte[]]) { [System.Text.Encoding]::UTF8.GetString($resp.Content) } else { [string]$resp.Content }
# If-Match requires the quoted ETag form; pwsh strips the quotes on read.
$etag = '"' + ([string]($resp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
return @{ Value = [int]$raw.Trim(); ETag = $etag }
} catch {
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
if ($status -eq 404) {
Write-Error "Counter blob '$counterBlob' not found. Seed it once with the last used build number (see docs/agent/build-counter-blob.md) -- do NOT seed from the repo's buildcounter.txt, it is a stale offline fallback."
}
throw
}
}
$buildNumber = $null
for ($attempt = 1; $attempt -le 5; $attempt++) {
$counter = Get-Counter
if ($override -ne '') {
$target = [int]$override
if ($target -le $counter.Value) {
# Re-build of an already-reserved number: counter stays monotonic, no write.
Write-Host "Manual build number $target <= counter $($counter.Value) -- counter blob unchanged"
$buildNumber = $target
break
}
Write-Host "Manual build number $target > counter $($counter.Value) -- advancing counter"
} else {
$target = $counter.Value + 1
}
try {
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'If-Match' = $counter.ETag }
Invoke-RestMethod -Uri "$counterUrl`?$sasToken" -Method Put -Headers $headers -Body "$target" -ContentType 'text/plain' | Out-Null
$buildNumber = $target
Write-Host "Reserved build number ${target} (CAS write ok, attempt $attempt)"
break
} catch {
$status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 }
if ($status -eq 412) {
Write-Host "CAS conflict on attempt $attempt (another build reserved concurrently) -- retrying"
Start-Sleep -Seconds ($attempt * 2)
} else {
throw
}
}
}
if ($null -eq $buildNumber) {
Write-Error "Could not reserve a build number after 5 CAS attempts"
}
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
if ($csproj -match '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
@@ -228,8 +177,13 @@ jobs:
Write-Warning "Could not parse bootstrap `$ScriptVersion from $env:BOOTSTRAP_SOURCE"
}
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above)
# Enrich the per-line manifest emitted by MSBuild (version-v2.json, read above).
# commit/buildUtc are the same two fields the backend and MCP manifests carry —
# additive, and the bootstrap reads this file by field name, so older installers
# ignore them. sha256/bootstrapVersion stay: those are the customer contract.
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
$manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
$manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
if ($bootstrapVersion) {
$manifest | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force
}
@@ -240,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"'
@@ -277,7 +231,12 @@ jobs:
param([string]$Local, [string]$BlobName, [string]$ContentType)
$url = "$containerUrl/$BlobName`?$sasToken"
$bytes = [System.IO.File]::ReadAllBytes($Local)
$headers = @{ "x-ms-blob-type" = "BlockBlob"; "Content-Type" = $ContentType }
# no-cache on EVERY blob: they all rotate in place (even the per-line ZIP is
# overwritten each build), and the download alias fronts them with Front Door.
# Route caching is disabled there, but a stale manifest paired with a fresh ZIP
# (independent cache entries) would fail the bootstrap SHA check -- this header
# keeps that class of bug impossible even if caching is ever re-enabled.
$headers = @{ "x-ms-blob-type" = "BlockBlob"; "Content-Type" = $ContentType; "x-ms-blob-cache-control" = "no-cache" }
if ($ContentType -eq "application/zip") {
$md5 = [System.Security.Cryptography.MD5]::Create()
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
@@ -311,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
@@ -352,13 +317,13 @@ jobs:
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload build artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: version-metadata
path: ${{ env.MANIFEST_NAME }}
+145
View File
@@ -0,0 +1,145 @@
name: CI
# Tests, typechecks and drift-checks every push to main and every pull request:
# the .NET suites, the web and MCP suites, and a guard that the generated rule
# files still match rules/guardrails.json.
#
# The deploy workflows build but never test, so this is the only place the suites
# actually run. It does not gate anything — main is deliberately unprotected, so a
# red run reports rather than blocks. Read the result and follow up.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
dotnet:
name: .NET tests (backend + agent)
# windows-latest is required: the agent test projects target net48.
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore AutopilotMonitor.sln
- name: Build
run: dotnet build AutopilotMonitor.sln --configuration Release --no-restore
- name: Backend tests
run: dotnet test src/Backend/AutopilotMonitor.Functions.Tests --configuration Release --no-build --verbosity quiet
- name: Agent V2 Core tests
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --no-build --verbosity quiet
- name: Decision Core tests
run: dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests --configuration Release --no-build --verbosity quiet
- name: Summary Dialog tests
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet
web:
name: Web (typecheck + tests)
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/Web/autopilot-monitor-web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: src/Web/autopilot-monitor-web/package-lock.json
- 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
mcp:
name: MCP server tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/McpServer/autopilot-monitor-mcp
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: src/McpServer/autopilot-monitor-mcp/package-lock.json
- run: npm ci
- 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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Regenerate
run: node rules/scripts/combine.js
- name: Fail if the generated files drifted
run: |
if ! git diff --quiet; then
echo "::error::Generated files are out of date. Run 'node rules/scripts/combine.js' and commit the result."
git diff --stat
exit 1
fi
+73
View File
@@ -0,0 +1,73 @@
name: CodeQL
# Documentation-only changes cannot affect what CodeQL analyzes (C# and
# TypeScript), so they are filtered out rather than spending ~10 minutes to
# re-report an unchanged result. A commit that touches docs AND code still runs:
# GitHub skips only when EVERY changed path matches the filter.
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'LICENSE'
schedule:
# Weekly, so advisories published after a merge still surface. Deliberately
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
# is also the backstop if the filters above ever skip too much.
- cron: '17 3 * * 1'
permissions:
contents: read
jobs:
analyze:
name: Analyze ${{ matrix.language }}
runs-on: ${{ matrix.runner }}
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
# csharp needs the net48 agent projects to build, so Windows.
- language: csharp
runner: windows-latest
build-mode: manual
- language: javascript-typescript
runner: ubuntu-latest
build-mode: none
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
if: matrix.language == 'csharp'
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Build
if: matrix.build-mode == 'manual'
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v4.37.3
with:
category: /language:${{ matrix.language }}
+3 -3
View File
@@ -22,11 +22,11 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
- name: Install ajv-cli
run: npm install -g ajv-cli ajv-formats
+86 -6
View File
@@ -7,13 +7,19 @@ permissions:
id-token: write # OIDC federated login (secret-less)
contents: read
env:
FUNCTION_APP: autopilotmonitor-api-eu
HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy Backend
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Login to Azure (OIDC)
uses: azure/login@v3
@@ -23,21 +29,95 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup .NET 10
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
# Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
# against a per-component counter blob. BuildTimestampUtc is pinned here rather
# than left to MSBuild's default so the published manifest and the value baked
# into the assembly (reported by /api/health) are the same instant.
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
throw "VersionPrefix not found in ${{ env.CSPROJ_PATH }}"
}
$version = "$($Matches[1]).$buildNumber"
$buildUtc = [DateTime]::UtcNow.ToString('o')
Write-Host "Backend version: $version (built $buildUtc)"
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
- name: Restore dependencies
run: dotnet restore src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
run: dotnet restore ${{ env.CSPROJ_PATH }}
- name: Build
run: dotnet build src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }}
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Publish
run: dotnet publish src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }}
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@v1
with:
app-name: autopilotmonitor-api-eu
app-name: ${{ env.FUNCTION_APP }}
package: ./publish
# A green deploy step only means the package was accepted. This gate makes the
# workflow assert what is actually being served, so the manifest published below
# can be trusted as "this is live" rather than "this was uploaded".
# /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
- name: Verify deployed build is live
shell: pwsh
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
$expectedVersion = '${{ steps.version.outputs.version }}'
$deadline = [DateTime]::UtcNow.AddMinutes(3)
$attempt = 0
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
exit 0
}
Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commitHash) -- waiting for $expectedVersion @ $expectedCommit"
} catch {
Write-Host "Attempt ${attempt}: /api/health not answering yet -- $($_.Exception.Message)"
}
Start-Sleep -Seconds 10
}
throw "Backend did not report $expectedVersion @ $expectedCommit within 3 minutes -- deployment not verified, version manifest NOT published"
# The manifest is the cross-component record of what is deployed where. It is
# written last and only on a verified deploy, so a difference between the
# manifest and /api/health means the running app drifted after the fact
# (rollback, stale instance) rather than a half-finished pipeline.
- name: Publish version manifest
shell: pwsh
run: |
$manifest = [ordered]@{
component = 'backend'
version = '${{ steps.version.outputs.version }}'
commit = '${{ github.sha }}'.Substring(0, 7)
buildUtc = '${{ steps.version.outputs.build_utc }}'
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json?$sas"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/backend.json"
Write-Host $manifest
+100 -1
View File
@@ -12,6 +12,9 @@ env:
CONTAINER_APP_NAME: autopilotmonitor-mcp
RESOURCE_GROUP: ${{ secrets.AZURE_MCP_RESOURCE_GROUP }}
IMAGE_TAG: ${{ github.sha }}
HEALTH_URL: https://mcp.autopilotmonitor.com/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
jobs:
build-and-deploy:
@@ -19,7 +22,23 @@ jobs:
name: Build and Deploy MCP Server
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
# The published customer documentation lives in its own PUBLIC repo and is
# baked into the image as the search_docs corpus (see the Dockerfile). No
# token is needed. Checking it out here rather than vendoring it keeps the
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
# captured below so that staleness is checkable.
- name: Check out documentation bundle
uses: actions/checkout@v7
with:
repository: autopilotmonitor/autopilotmonitor-docs
path: mcp-docs
- name: Resolve docs commit
id: docs
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Login to Azure (OIDC)
uses: azure/login@v3
@@ -28,6 +47,30 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Same counter mechanism as agent and backend: reserve-before-build with
# ETag-CAS against the component's own counter blob. major.minor stays curated
# in package.json; only the build part is minted here.
- name: Reserve build number (counter blob CAS)
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt" -SasToken '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
$version = "$prefix.$buildNumber"
$buildUtc = [DateTime]::UtcNow.ToString('o')
Write-Host "MCP version: $version (built $buildUtc)"
# 7 chars, matching what the backend's BackendBuildInfo reports — one commit
# shape across all components so the values can be compared as-is.
$shortSha = '${{ github.sha }}'.Substring(0, 7)
"build_number=$buildNumber" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
"short_sha=$shortSha" >> $env:GITHUB_OUTPUT
- name: Login to ACR
run: az acr login --name ${{ env.ACR_NAME }}
@@ -35,6 +78,10 @@ jobs:
run: |
docker build \
-f src/McpServer/autopilot-monitor-mcp/Dockerfile \
--build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \
--build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
--build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
--build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
.
@@ -46,3 +93,55 @@ jobs:
--name ${{ env.CONTAINER_APP_NAME }} \
--resource-group ${{ env.RESOURCE_GROUP }} \
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
# `az containerapp update` returns once the revision is provisioned, not once
# it serves traffic — and with minReplicas=0 the first request also pays a cold
# start. Polling /health until it reports THIS build is what makes the manifest
# below mean "live"; a full 5 minutes because the cold start alone is ~25s.
- name: Verify deployed build is live
shell: pwsh
run: |
$expectedCommit = '${{ steps.version.outputs.short_sha }}'
$expectedVersion = '${{ steps.version.outputs.version }}'
$deadline = [DateTime]::UtcNow.AddMinutes(5)
$attempt = 0
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60
if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) {
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)"
exit 0
}
Write-Host "Attempt ${attempt}: serving $($health.version) @ $($health.commit) -- waiting for $expectedVersion @ $expectedCommit"
} catch {
Write-Host "Attempt ${attempt}: /health not answering yet -- $($_.Exception.Message)"
}
Start-Sleep -Seconds 15
}
throw "MCP did not report $expectedVersion @ $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
# Cross-component record of what is deployed where; written last and only on a
# verified deploy. docsCommit rides along because for this component "which
# build" and "which documentation" are two separate questions.
- name: Publish version manifest
shell: pwsh
run: |
$manifest = [ordered]@{
component = 'mcp'
version = '${{ steps.version.outputs.version }}'
commit = '${{ steps.version.outputs.short_sha }}'
buildUtc = '${{ steps.version.outputs.build_utc }}'
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
} | ConvertTo-Json
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json?$sas"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/mcp.json"
Write-Host $manifest
+22 -2
View File
@@ -21,16 +21,28 @@ jobs:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy Job
# Dependabot PRs run without repo secrets, so the deploy token is absent.
# Surface it as an env var so the deploy step can gate on it — secrets
# cannot be referenced directly in an `if:` expression.
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
- name: Build And Deploy
id: builddeploy
# Skip (green) instead of failing when the token is missing (Dependabot
# PRs); real deploys on main and internal PRs still have it.
if: env.DEPLOY_TOKEN != ''
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 }}
@@ -44,16 +56,24 @@ 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:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request Job
env:
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
steps:
- name: Close Pull Request
id: closepullrequest
if: env.DEPLOY_TOKEN != ''
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
+61
View File
@@ -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.'
+11 -2
View File
@@ -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/
@@ -117,8 +120,11 @@ scripts/Deployment/
# Migration tooling (local only, vorerst) — cross-tenant storage/cert migration scripts
scripts/Migration/
# Infra (local only)
infra/
# Infra: tracked. The Bicep templates carry no credentials — every secret is a
# @secure() parameter with an empty default, injected at deploy time, and image
# pull runs on managed identity. Keep it that way: never commit a resolved secret
# value or a *.parameters.json holding one.
infra/*.parameters.json
# M2 — local raw session fixtures before anonymization (plan §4 M2)
tests/fixtures/enrollment-sessions-local/
@@ -145,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/
+4
View File
@@ -3,6 +3,10 @@
"autopilot-monitor": {
"type": "http",
"url": "https://mcp.autopilotmonitor.com/mcp"
},
"playwright": {
"command": "cmd",
"args": ["/c", "npx", "@playwright/mcp@latest"]
}
}
}
+12
View File
@@ -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>
+4 -4
View File
@@ -1,14 +1,14 @@
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
[![Status](https://img.shields.io/badge/status-Private%20Preview-orange)](https://www.autopilotmonitor.com)
[![Status](https://img.shields.io/badge/status-live-brightgreen)](https://www.autopilotmonitor.com)
[![License](https://img.shields.io/badge/license-MIT%20%2B%20AGPL--3.0-blue)](LICENSE)
[![Website](https://img.shields.io/badge/website-autopilotmonitor.com-2ea44f)](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%" />
@@ -34,7 +34,7 @@ For detailed information about the system architecture, components, and data flo
## Documentation
Full documentation is available at **[autopilotmonitor.com/docs](https://www.autopilotmonitor.com/docs)**
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
## License
+21
View File
@@ -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
@@ -119,6 +127,19 @@ normal IME deployment flow. Spam containment, in order:
payload; the event's own timestamp is the emission time, so the session's `StartedAt`
(which the backend aligns to the earliest event) is not dragged backwards.
Presentation (2026-07-28, customer feedback — an unannounced wall of pre-agent Error
events read like a live failure):
- The scan buffers the (capped) record set, first emits an **Info summary event**
(`modern_deployment_log`, payload `backfillSummary: true` + `replayedCount` /
`droppedOverCap` / `lookbackMinutes`) announcing the replay block, then replays the
records **oldest-first** so the timeline sequence follows event-log chronology.
- The web timeline (`EventTimeline.tsx`) badges `backfilled: true` events („⟲ Backfilled",
slate, explanatory tooltip) and renders the original `timeCreated` as the row time with a
"(reported …)" suffix; the expanded metadata block gains a `Recorded` line. Severity is
deliberately **not** downgraded — unresolved pre-agent errors must still surface in the
Error filter.
# Schema — Error codes (backend known-issue map)
Codes documented in the known-issues / troubleshooting-FAQ pages that appear in ESP/enrollment
+16 -19
View File
@@ -22,26 +22,21 @@ attestation on top.
# Schema
- **Counter blob**: `https://autopilotmonitor.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
(per release line, analogous to `version-v{LINE}.json`). Content: the **last used**
build number as plain text. Public-read like the rest of the container (the number
is visible in `version-v{LINE}.json` anyway); writes require the container SAS.
- **Protocol — reserve before build, ETag-CAS**:
1. Anonymous `GET` the blob, capture value `N` and the `ETag`.
2. `PUT N+1` with `If-Match: <etag>` (quoted form — pwsh strips the quotes on read,
they must be re-added or the request is rejected client-side).
3. `412 Precondition Failed` → another build reserved concurrently → re-read, retry
(5 attempts, linear backoff).
4. Build with `BUILD_NUMBER=N+1` in the environment. The csproj honors
`BUILD_NUMBER` and then skips its `PersistBuildCounter` target, so the repo file
is not touched.
- **Failed builds burn a number.** That is intentional: uniqueness matters, density
does not. Reserve-before-build is what makes concurrent minting impossible.
The reserve-before-build CAS protocol itself is shared with the backend and the MCP
server and is documented once in [Version Contract](../versioning.md); the single
implementation is `.github/scripts/Request-BuildNumber.ps1`. Agent-specific:
- **Counter blob**: `https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v{LINE}.txt`
— one per release line, analogous to `version-v{LINE}.json`, and kept in the `agent`
container next to the artifacts it numbers rather than in `versions/`. Content: the
**last used** build number as plain text. Public-read like the rest of the container
(the number is visible in `version-v{LINE}.json` anyway); writes require the SAS.
- The reserved number reaches the build as `BUILD_NUMBER` in the environment. The
csproj honors it and then skips its `PersistBuildCounter` target, so the repo file
is not touched.
- **CI manual override** (`build_number` input): numbers `<=` counter are re-builds
and leave the blob unchanged; numbers `>` counter advance it (monotonic guard).
- **Seeding** (one-time per line): `PUT <last used number>` with `If-None-Match: *`
so an existing counter can never be clobbered. V2 was seeded with `1356` on
2026-07-17.
- V2 was seeded with `1356` on 2026-07-17.
# Consequences
@@ -62,7 +57,9 @@ attestation on top.
# Citations
- [Version Contract](../versioning.md) — the shared CAS protocol and manifest format
- `.github/workflows/build-agent.yml` — "Reserve build number (counter blob CAS)" step
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — `Request-BuildNumber`
- `.github/scripts/Request-BuildNumber.ps1` — the shared implementation
- `scripts/Deployment/build.ps1` (gitignored, contains SAS) — local build path
- `src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj` —
`BUILD_NUMBER` property + `PersistBuildCounter` target
+114
View File
@@ -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.
+11 -2
View File
@@ -137,8 +137,17 @@ false` and an arm holds), `EspExiting`, `ImeUserSessionCompleted`,
advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
1. **Completion gates** (currently only the RealmJoin AND-gate): if RealmJoin was
detected and is neither resolved nor timed-out (hard 60-min timeout), completion is
*deferred* — the session stays in stage and emits `completion_waiting`.
detected and has no terminal outcome yet, completion is *deferred* — the session
stays in stage and emits `completion_waiting`. Three terminals open the gate:
`Resolved` (DeploymentPhase 110), `FirstDeploymentIncomplete` (phase left the
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
typically by an interactive logon reclassifying the run as secondary-user; emits
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
`Timeout` (hard 60-min deadline). The abort rule reads the *persisted*
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
evaluates it too. A first observation of 210 does **not** release (RJ may have
completed before agent boot and deploy afterwards).
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
timeline event with full audit payload.
+151
View File
@@ -0,0 +1,151 @@
---
type: Concept
title: ESP Policy-Provider Stall Detection — the no-timeout Setup/Apps wait and the co-management Sidecar gap
description: Why the ESP's Apps wait is keyed to the Intune-registered "Sidecar" provider by name — a foreign provider (ConfigMgr) with TrackingPoliciesCreated=1 does not satisfy it — plus the classic never-completes stall, and how the always-on two-arm esp_policy_provider_stalled tripwire (reason=provider_incomplete | sidecar_provider_missing, 15-min wall-clock dwell, StartupEventGate cross-restart dedup) surfaces both.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspPolicyProviderProbe.cs
tags:
- agent
- esp
- enrollment-status-tracking
- co-management
- stall-detection
timestamp: 2026-07-24T00:00:00+02:00
---
# ESP Policy-Provider Stall Detection
## Schema
The Enrollment Status Page decides when its "Apps" category may progress from
"Identifying" using the **EnrollmentStatusTracking CSP**
(<https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>),
mirrored in the registry under
`HKLM\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\{Device|<UserSID>}\…`.
Two contracts matter:
1. **`Setup/Apps/PolicyProviders/<Name>`** — per the CSP doc: *"Existence of this node
indicates to the ESP that it shouldn't show the tracking status message until the
TrackingPoliciesCreated node has been set to true."* This wait has **no timeout**. A
registered provider that never sets `TrackingPoliciesCreated=1` parks the ESP at
"Apps (Identifying)" indefinitely — observed for days in the field. Exists in device
scope (`Device`) and user scope (`S-<SID>`, appears at sign-in).
2. **`DevicePreparation/PolicyProviders/<Name>/InstallationState`** — must reach 2
(NotRequired) or 3 (Completed); 1=NotInstalled, 4=Error. Device scope only. The ESP
applies a default 15-minute timeout here, so these stalls self-surface as ESP errors —
the detector still records them for the diagnostic trail.
Known provider names: **`Sidecar` is the Intune Management Extension** (the CSP doc names
"Intune's agents, such as SideCar"); **`ConfigMgr`** is the Configuration Manager client,
registered during Autopilot-into-co-management — the troubleshooting section of
<https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment> queries
exactly these registry paths. The same document states that ESP policy providers **are not
aware of each other** and that **pre-provisioning is unsupported** with Autopilot into
co-management.
## The field case (issue #106)
A tenant pre-provisions devices and installs the ConfigMgr client as a blocking app.
Occasionally the user-phase ESP reaches "Apps (Identifying)" and never leaves it — for
hours to days. On those devices `Device\Setup\Apps\PolicyProviders` contains **only a
`ConfigMgr` key (no `Sidecar`)** — and, per the customer's follow-up, **`TrackingPoliciesCreated=1`
IS set under it**. The provider is "complete" by the CSP value contract, yet the ESP
still waits. Manually renaming the key to `Sidecar` — which creates no tracking entries,
only satisfies the name — unblocks the ESP instantly.
The naive CSP-doc reading ("any provider with TrackingPoliciesCreated=1 satisfies the
wait") is therefore wrong for this case. What the evidence supports instead: provider
nodes are registered **by the Intune service at enrollment**, and its ESP-policy WAP
payload always registers `Sidecar` (ConfigMgr joins only via a co-management settings
policy — see the payload documented by Niehaus). The ESP waits on the provider it
expects **by name**; a foreign provider occupying the slot does not satisfy it,
regardless of its completion values. On the affected devices the CM client (installed
as a plain Win32 app, no co-management settings policy) evidently displaced the
Sidecar node — consistent with Microsoft's warning that providers "aren't currently
aware of others" and that pre-provisioning is unsupported with co-management. The exact
CloudExperienceHost mechanism is not publicly documented; the registry signature —
setupApps providers present, `Sidecar` absent — is deterministic either way.
## Detection
`EspPolicyProviderProbe` (fail-soft, static, `ScopedOverride` test seam — structural clone
of `EspTrackingInfoProbe`) enumerates all providers in both kinds and scopes.
`EspPolicyProviderStallDetector` evaluates a pure transition function on a 60-second tick
from the always-on `EspPolicyProviderStallHost` — **two arms**, same 15-minute wall-clock
dwell, one shared event type discriminated by `reason`:
* **Arm 1, `reason=provider_incomplete`** — a provider **continuously incomplete for
≥ 15 minutes**. The dwell clock starts at first incomplete observation and resets on
completion or disappearance. Missing values parse as incomplete
(fail-toward-observation). Catches e.g. Sidecar itself never reporting.
* **Arm 2, `reason=sidecar_provider_missing`** — **≥ 1 `Setup\Apps` provider registered
but none named `Sidecar`**, continuously for ≥ 15 minutes. Deliberately ignores
provider completeness (the field case had `TrackingPoliciesCreated=1`). Sidecar
registering later — the legitimate startup ordering — clears the condition and resets
the clock. DevicePreparation-only snapshots don't activate it.
* When both arms cross in the same round, ONE event is emitted and
`sidecar_provider_missing` wins as `reason` (it explains the hang regardless of the
foreign provider's state).
* Missing root key / empty provider list = normal early enrollment: clears all dwell
state (both arms), never fires.
* One `esp_policy_provider_stalled` **Warning** per newly stalled condition set
(`ImmediateUpload`), one-shot per condition in-process, cross-restart deduped via a
`StartupEventGate` fingerprint over the sorted currently-active fired conditions
(provider keys + a `sidecarMissing` sentinel) — a restart into the same stall stays
silent, a new condition joining re-reports.
* Payload: `reason`, `providers[]` (full table: name, scope, kind, raw values, complete
flag), `stalledProviders[]` with `stalledForMinutes`, `sidecarMissingForMinutes`
(arm 2 only), `sidecarRegistered`, `dwellMinutes`.
* **Purely observational**: emitted via `InformationalEventPost` (dispatch-guard exempt
pass-through) — never touches decision state. No config toggle, no ConfigVersion bump
(precedent: `disk_space_low`).
Deliberately **not** built on `StallProbeCollector` (its idle clock resets on any session
activity — a provider stall coexists with a chatty session) and not on the periodic
collectors (idle-stopped after 15 minutes — exactly the dormant window the dwell must
observe).
## Guardrail companion
`rules/guardrails.json` allow-lists `SOFTWARE\Microsoft\Windows\Autopilot` (category
"Autopilot / OOBE / Setup") so tenants can build their own gather rules on the subtree —
e.g. a `not_exists` registry rule on the `Sidecar` provider key. The subtree carries
deployment state only (provider registrations, tracking lists, app IDs, per-user SIDs);
sensitivity is at or below existing entries. Enforcement is agent-side (embedded
resource), so both the detector and the guardrail change ship with the agent release.
## Examples
Customer-case event (abbreviated) — note the provider is "complete" by CSP values, the
hang is carried entirely by `reason=sidecar_provider_missing`:
```json
{
"eventType": "esp_policy_provider_stalled",
"severity": "Warning",
"data": {
"reason": "sidecar_provider_missing",
"dwellMinutes": 15,
"sidecarRegistered": false,
"sidecarMissingForMinutes": 15.0,
"providers": [
{ "name": "ConfigMgr", "scope": "device", "kind": "setupApps",
"trackingPoliciesCreated": 1, "installationState": null, "complete": true }
],
"stalledProviders": []
}
}
```
`reason=sidecar_provider_missing` is the co-management signature;
`reason=provider_incomplete` with `sidecarRegistered=true` covers a stalled Sidecar or
third-party provider — the generalized contract violation.
# Citations
* EnrollmentStatusTracking CSP — <https://learn.microsoft.com/en-us/windows/client-management/mdm/enrollmentstatustracking-csp>
* Windows Autopilot into co-management (requirements, provider-conflict warning, troubleshooting registry paths) — <https://learn.microsoft.com/en-us/intune/configmgr/comanage/autopilot-enrollment>
* Troubleshoot the ESP (models app tracking exclusively via the `Sidecar` subkey) — <https://learn.microsoft.com/en-us/troubleshoot/mem/intune/device-enrollment/understand-troubleshoot-esp>
* Niehaus: the Intune ESP-policy WAP payload registering the provider nodes (`Sidecar` always, `ConfigMgr` only with a co-management settings policy) — <https://oofhours.com/2023/08/25/run-an-sccm-task-sequence-during-autopilot/>
* Niehaus: ESP tracks arbitrarily named providers when they are properly registered — <https://oofhours.com/2023/09/18/track-anything-using-esp/>
* Agent code: `EspPolicyProviderProbe.cs`, `EspPolicyProviderStallDetector.cs`, `EspPolicyProviderStallHost.cs` (SystemSignals / Orchestration.Hosts)
* Related: [MDM Reboot Coalescing](mdm-reboot-coalescing.md), [Gather-Rule Guardrails](../rules/gather-rule-guardrails.md)
+126
View File
@@ -0,0 +1,126 @@
---
type: Concept
title: Gather Rule Debug Log — the per-tenant evaluation trace for rules that produce nothing
description: EnableGatherRuleDebugLog (ConfigVersion 36) makes the agent write a local trace file explaining every gather-rule evaluation decision — scope skips, missing intervalSeconds, on_change suppression, empty collector results, logparser position/parse-mode/no-match outcomes — so customers can self-diagnose rules that never show up in the timeline.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs
tags:
- agent
- gather-rules
- diagnostics
- tenant-configuration
timestamp: 2026-07-28T00:00:00+02:00
---
# Gather Rule Debug Log
## Schema
### The problem
A gather rule that produces no timeline events is indistinguishable from a rule that
never ran. The pipeline has many *silent-nothing* exits — before this feature, none of
them were visible to the customer:
| # | Decision point | Where |
|---|---|---|
| 1 | Rule delivered but `Enabled=false` | `GatherRuleExecutor.UpdateRules` |
| 2 | `trigger=interval` without `intervalSeconds` — never runs | `UpdateRules` timer filter |
| 3 | Interval timer's first tick comes only after one **full** interval | `UpdateRules` timer creation |
| 4 | Out of phase scope (`activePhases` / `activeFromPhase` not latched); incl. `currentPhase=Unknown` before the first phase signal | `IsRuleInScopeLocked` |
| 5 | phase_change / phase_exit dedup — once per (rule, phase) | `OnPhaseChanged` |
| 6 | Collector returned an **empty** result (e.g. `emitOnlyIfExists=true` and target absent) | `ExecuteRule` |
| 7 | `emit_mode=on_change` — result hash unchanged, emit suppressed | `ShouldEmitOnChange` |
| 8 | Guard blocked the target (emits `security_warning`, but the rule yields no data) | `GatherRuleContext.EmitSecurityWarning` |
| 9 | logparser: no files matched, position tracker says nothing new, regex no-match, plain-text file parsed in default `cmtrace` mode, `maxLines` cap, wildcard capped at 20 files | `LogParserCollector` |
| 10 | Rule execution threw (agent log had only the message, no stack) | `ExecuteRule` catch |
### The setting
`TenantConfiguration.EnableGatherRuleDebugLog` (`bool?`, default null→false), served to the
agent as `AgentConfigResponse.EnableGatherRuleDebugLog` — **ConfigVersion 36**. Portal:
Settings → Agent → Settings, toggle "Gather Rule Debug Log", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report.
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
security-sensitive and deliberately **survives the offline config cache**.
### The trace file
`GatherRuleDebugLog` is an append-only writer held by `GatherRuleExecutor` and exposed
to collectors via `GatherRuleContext.DebugLog(ruleId, stage, message)` (null-safe no-op
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), `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; 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
leaves the device on its own — but because it lives in the agent log folder
(`Constants.LogDirectory`), the diagnostics package (`DiagnosticsPackageService`,
`*.log` pattern, `AgentLogs/` section) picks it up automatically, so a customer-initiated
diagnostics upload hands the trace to support without manual copying.
### Semantics
* **No hot-reload** — the flag is consumed once when `DefaultComponentFactory` builds
the `GatherRuleExecutorHost` at session start (per-enrollment lifecycle, same as
`EnableImeMatchLog`). Enable it in the tenant, then the *next* enrollment writes the
trace.
* **Zero-rules case is traced too** — flag on + backend delivered no gather rules →
a one-shot standalone `config` line, so the file is never just absent.
* **`--run-gather-rules` always traces** (operator diagnostic mode; CLI override or
default path) and mirrors every line to the console under `--console`.
## Examples
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
```
— 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`
* Writer: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleDebugLog.cs`
* Config projection: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* Phase scoping / emit mode background: `../rules/gather-rule-phase-scoping.md`
+1
View File
@@ -7,5 +7,6 @@ How the on-device agent works:
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
Reading order for newcomers: overview → decision engine → persistence.
+100
View File
@@ -0,0 +1,100 @@
---
type: Concept
title: MDM Reboot Coalescing — attributing the mid-ESP reboot and second sign-in to policy URIs
description: How the agent observes reboot-required MDM policy flags (DM-Enterprise EventID 2800, aggregated) and how ANALYZE-ESP-005 attributes an actually-observed enrollment reboot to them — including why the raw events stay neutral and why the Shell-Core RebootCoalescing match was removed.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
tags:
- agent
- esp
- reboot
- policy-attribution
- second-sign-in
timestamp: 2026-07-20T00:00:00+02:00
---
# MDM Reboot Coalescing
Device-assigned MDM policies whose CSP URIs match the OS reboot-required catalog
(`HKLM\SOFTWARE\Microsoft\Provisioning\SyncML\RebootRequiredURIs`) force a **coalesced
reboot at the end of ESP DeviceSetup** — the user sees an unexpected restart and a
**second sign-in screen** before AccountSetup. Invisible in the Intune console; most
admins live with it, not knowing that reassigning the profiles to user groups removes the
reboot entirely. Source research: Patch My PC,
["Avoid the second sign-in screen after Autopilot"](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/).
The session already counts reboots (`system_reboot_detected` → `session.rebootCount`);
this feature adds the **cause attribution**.
# Verified signal facts (session b2e890c1, 2026-07-20)
* **EventID 2800**, `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin`,
one record per URI, verified description shape:
`The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).`
Records arrive in sub-second bursts during policy sync.
* **Timing semantics (the critical nuance)**: 2800 fires at EVERY policy sync. Only URIs
applied **during ESP DeviceSetup** are coalesced into the forced mid-ESP restart. In the
verified session the flags fired during AccountSetup (after DeviceSetup completed) —
**no reboot occurred**, and `omadmRebootRequiredFlag` read `false`. A detector that
claims "reboot incoming" on every 2800 is therefore wrong.
* **Shell-Core 62407 "RebootCoalescing" — REMOVED as a signal.** The token appears in the
ROUTINE bootstrap marker on every enrollment:
`Name: 'CommercialOOBE_BootstrapStatusCategory_SubcategoryProcessing_Started', Value: '{"message":"BootstrapStatus: Starting subcategory DeviceSetup.RebootCoalescing...","errorCode":0}'`
— the ESP merely *processes* the RebootCoalescing subcategory (which checks whether a
reboot is needed). A substring match on it is a false positive by construction. If a
real coalesced-reboot record is ever captured on the E2E VM, a precise pattern can be
added then.
# Detector — `MdmRebootPolicyTracker` (`MdmRebootPolicyWatcherHost`)
Live `EventLogWatcher` on the DM-Enterprise Admin channel (EventID 2800) + startup
backfill (default 60 min lookback). Records are **buffered and debounced (10 s)**, then
flushed as **ONE aggregated `mdm_policy_reboot_required` event** per burst:
* `Severity=Info`, neutral message ("Windows flagged N MDM policy URIs as
reboot-required: … (+N more)") — the raw event never claims a reboot.
* `Data`: `rebootUris` (distinct, sorted, capped 20), `uriCount` (uncapped distinct),
`firstRebootUri` (record-time order, drives rule interpolation), `recordCount`,
`unparsedCount`/`sampleDescription` (extraction diagnostics), `backfilled` (all-backfill),
fail-soft `omadmRebootRequiredFlag` probe of
`HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\RebootRequired` (null = unknown).
* `Timestamp` = earliest record time (backfilled bursts land at their historical instant);
`ImmediateUpload=true` (one event per burst is cheap, and a DeviceSetup burst precedes a
reboot that kills the agent).
* **Watermark is persisted at FLUSH, not at claim** (`mdm-reboot-watermark.json`): if the
coalesced reboot kills the process before the debounced emit, nothing is marked — the
post-restart backfill re-reads and emits the burst. A flushed burst is never repeated.
URI extraction: EventData values starting with `./` preferred, permissive regex over the
description as fallback (trailing punctuation trimmed — the verified text wraps the URI in
parens and ends with a period). Unparsed records still count into the aggregate.
Config (internal steering only, no admin UI — agent-native collector, NOT an
`IAgentAnalyzer`): `Collectors.MdmRebootPolicyWatcherEnabled` (default true),
`Collectors.MdmRebootPolicyBackfillLookbackMinutes` (default 60). ConfigVersion 35.
# Advisory — ANALYZE-ESP-005
The reboot **claim** lives here, and only here. Required conditions (BOTH):
1. `mdm_policy_reboot_required.firstRebootUri` exists (→ `{{firstRebootUri}}` interpolation)
2. `system_reboot_detected` count ≥ 1 — **an actually-observed reboot**
Policy flags alone (e.g. AccountSetup user-policy sync) stay advisory-silent. Optional
confidence: `uriCount ≥ 2` (+10), `omadmRebootRequiredFlag == true` (+15) on base 70 /
threshold 70. Remediation: map URIs to Intune profiles, reassign device → user groups (or
exclude the Autopilot device group), or accept + communicate the restart.
# Non-goals / invariants
* Event type **deliberately distinct from `system_reboot_detected`** — the backend
RebootCount matches that string exactly in two places
(`EventIngestProcessor.Classification` + terminal recount); attribution must never
inflate the reboot count.
* No session-level field / SessionInfoCard change in this iteration (follow-up candidate).
* The `RebootRequiredURIs` catalog itself is not read; only the 2800 events (which carry
the actually-triggered URIs) are consumed.
# Citations
* [Patch My PC — Avoid the second sign-in screen after Autopilot](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/)
* [Microsoft Learn — Enrollment Status Page tracking](https://learn.microsoft.com/en-us/autopilot/enrollment-status)
+100
View File
@@ -0,0 +1,100 @@
---
type: Concept
title: Secure Boot CA-2023 Detection — firmware evidence vs. Windows servicing status
description: Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly (GetFirmwareEnvironmentVariableExW + SeSystemEnvironmentPrivilege) instead of trusting the SecureBoot\Servicing registry key, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant rule suppression across old and new agents.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs
tags:
- agent
- secureboot
- ca2023
- analyze-rules
- backward-compatibility
timestamp: 2026-07-23T00:00:00+02:00
---
# Secure Boot CA-2023 Detection
## Schema
Two independent evidence sources describe the Windows UEFI CA 2023 rollout state:
1. **Servicing registry** (`HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing\UEFICA2023Status`)
— reflects whether the *Windows Update* rollout ran and self-verified. Lags reality:
devices whose firmware received the certificate another way (OEM factory image, WinCS
CLI, manual deployment) or where WU simply has not run yet show `unknown`/`notfound`.
2. **Firmware itself** — the UEFI variables `db` (signature database, GUID
`{d719b2cb-3d3a-4596-a3bc-dad00e67656f}`) and `KEK` (GUID
`{8be4df61-93ca-11d2-aa0d-00e098032b8c}`). Authoritative. Certificate subject CNs are
ASCII bytes inside the DER signature lists, so a byte-substring search for
`Windows UEFI CA 2023` / `Microsoft Corporation KEK 2K CA 2023` decides presence —
the same check `Get-SecureBootUEFI` enables from PowerShell.
`UefiSecureBootCertReader` (agent, `Monitoring/Interop`) reads both variables natively via
`GetFirmwareEnvironmentVariableExW` — no PowerShell runspace during OOBE. The call needs
`SeSystemEnvironmentPrivilege`: SYSTEM holds it default-disabled, so the reader enables it
on the process token (`AdjustTokenPrivileges`) and restores the previous state afterwards.
Fail-soft contract like `OobeStateReader`: never throws; failures map to status strings
(`not_uefi` = legacy BIOS via `ERROR_INVALID_FUNCTION`, `variable_not_found`,
`privilege_denied`, `error_<code>`).
This is the repo's only privilege-adjustment code, so it also carries the ownership rules
any future one should copy:
* The process token lives in a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR
owns it from the moment `OpenProcessToken` returns — the critical finalizer releases it
even if `Dispose` never runs (async exception before the instance reaches its `using`).
* The restore obligation is recorded immediately after the successful `AdjustTokenPrivileges`,
and only when the privilege was not *already* enabled; nothing that can throw sits between
the adjustment and that assignment. `Dispose` is idempotent, swallows a failed restore, and
releases the handle in a `finally`.
* The privilege is adjusted on the **process** token, so enable → read → restore is a
process-wide side effect and runs under a static gate. Without it a concurrent reader would
see "already enabled", correctly skip its own restore, and then lose the privilege mid-read
when the first reader disposes — a spurious `privilege_denied` on a machine that holds it.
Fields land on the existing `secureboot_status` event (once per agent start,
restart-deduped by the StartupEventGate): `uefiFirmwareReadStatus`, per-cert booleans
(`uefiDbHasWindowsUefiCa2023`, `uefiDbHasWindowsProductionPca2011`,
`uefiKekHasMicrosoftKek2kCa2023`, …) — emitted only when the respective read succeeded —
plus the marker below.
## The one-sided marker (absence-tolerant suppression)
Rule-engine constraints (pinned by `RuleEnginePreconditionTests`):
* **Conditions** never match on an absent field (null guard) — new fields must stay
`required: false` or the rule dies for every old agent.
* **Preconditions**: `not_exists` passes while the field is absent/empty and fails as soon
as the field is present with *any* value; comparison operators fail closed on absence.
A two-sided boolean therefore cannot drive suppression: `not_exists` on
`uefiDbHasWindowsUefiCa2023` would also suppress the rule when the field is present with
`false`. Hence the **one-sided marker** `uefiCa2023FirmwareConfirmed = true`, emitted
ONLY when the db read succeeded AND the 2023 certificate was found — never as `false`.
ANALYZE-SEC-001 v3 adds a `not_exists` precondition on it:
| Device state | Marker | Rule |
|---|---|---|
| Old agent (no firmware fields) | absent | evaluates exactly like v2 |
| New agent, cert missing or firmware unreadable | absent | evaluates; optional conditions `uefiDbHasWindowsUefiCa2023=false` (+20) / `uefiKekHasMicrosoftKek2kCa2023=false` (+10) raise confidence |
| New agent, firmware confirms cert | present | silently suppressed (the customer-reported false positive) |
Because the marker exists nowhere until the new agent ships, the v3 rule is safe to seed
**before** the agent rollout.
## Examples
Customer-equivalent verification from PowerShell (authoritative, matches the reader):
```powershell
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023')
[Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name KEK).Bytes).Contains('Microsoft Corporation KEK 2K CA 2023')
```
## Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/UefiSecureBootCertReader.cs` — native reader
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — `CollectSecureBootStatus` / `AppendFirmwareCertFields`
* `rules/analyze/ANALYZE-SEC-001.json` — v3.0.0 rule definition
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleEnginePreconditionTests.cs` — pinned precondition semantics + v3 mirror tests
* Microsoft: [Act now — Secure Boot certificates expire June 2026](https://techcommunity.microsoft.com/blog/windows-itpro-blog/act-now-secure-boot-certificates-expire-in-june-2026/4426856)
+95
View File
@@ -0,0 +1,95 @@
---
type: Concept
title: Business Timestamps vs. the Azure Tables System Timestamp
description: Why the system Timestamp is never authoritative, how the 2026-07-18 migration exposed that, and the OccurredUtc + RowKey-decode compensation (mappers, RowKey-range date filters, retention, backfill).
resource: /src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs
tags:
- backend
- table-storage
- audit-log
- ops-events
- events
- migration
timestamp: 2026-07-20T00:00:00+02:00
---
# Problem
Azure Table Storage's `Timestamp` is a **system property**: the server ignores any supplied
value on write and stamps the row's *write* time; every row rewrite (storage-account
migration, MERGE) resets it. Three tables nevertheless treated it as the business event
time — writes like `["Timestamp"] = evt.Timestamp` were silent no-ops, unnoticed because
write time ≈ event time in normal operation.
The 2026-07-18 EU storage migration rewrote every row, so all migrated rows in
**AuditLogs**, **OpsEvents**, and **Events** read as "2026-07-18 ~13:58 UTC": displays were
wrong, date-window filters matched the wrong rows, and retention froze (nothing older than
the migration moment) with a mass-delete cliff one retention period later.
# Rule
**The system Timestamp is never authoritative.** Every table that needs an event time
stores it in the custom column `OccurredUtc` (`BusinessTimestamp.OccurredUtcColumn`).
Custom columns are copied verbatim by `scripts/Migration/Copy-TableStorage.ps1`, so future
migrations preserve chronology. Migration checklist: any table whose reads/filters depend
on a timestamp must carry it as a custom column — audit this before every storage move.
# Recovery sources per table
The original time survives migrations inside the RowKey:
| Table | RowKey scheme | Decodable |
|---|---|---|
| AuditLogs | `!{MaxTicks-ticks:D19}_{guid:N}` (since 2026-05-06) | Yes; legacy bare-GUID rows (pre-2026-05) are lost |
| OpsEvents | `{MaxTicks-ticks:D19}` | Yes, all rows |
| Events | `{ts:yyyyMMddHHmmssfff}_{seq:D10}` (sanitized agent time) | Yes, all rows (ms precision) |
# Mechanism
`BusinessTimestamp` (Functions/Helpers) is the single source of truth: RowKey decoders,
the OData clause builders, and `GetUtcDateTime` (datetime columns materialize as
`DateTimeOffset` *or* `DateTime` depending on payload shape — always read via this helper).
1. **Read mappers** resolve `OccurredUtc` → RowKey decode → system Timestamp (last
resort; only audit legacy GUID rows end there). Resolvers:
`TableStorageService.ResolveAuditTimestamp`, `TableOpsEventRepository.ResolveTimestamp`,
`TableStorageService.ResolveEventTimestamp` (also used by
`GetEarliestSessionEventTimestampAsync`, which feeds StartedAt/Duration reconciliation —
a system-Timestamp read there would rewrite migrated sessions' derived fields).
2. **Date-window filters and retention use RowKey ranges**, not property comparisons:
OData excludes rows missing a compared property (an `OccurredUtc` filter would drop
every not-yet-backfilled row), and RowKey ranges are index-backed. Boundary semantics
are tick-exact equivalents of the old `Timestamp ge/le` clauses.
**Trap:** audit legacy GUID RowKeys (hex first char) sort *after* all `!`-rows, so
every lower bound `RowKey ge '!…'` must be paired with `RowKey lt '"'`
(`AuditTimeEncodedUpperBound`) — without the guard, a retention sweep would delete all
legacy rows. Pinned by `BusinessTimestampFilterClauseTests`.
3. **Writes** in all three tables now set `OccurredUtc`; the no-op `["Timestamp"]`
entries were removed.
4. **Backfill** (`POST /api/maintenance/backfill-occurred-utc`, GlobalAdminOnly, dry-run
by default, `table=audit|ops`, batched via `maxRows`/`continuation`): MERGE that adds
only `OccurredUtc` decoded from the RowKey — deterministic and idempotent. **Events
rows are deliberately never backfilled** (read-path decode is sufficient; no bulk
mutation of enrollment evidence). Note: a MERGE resets the row's system Timestamp —
irrelevant once nothing depends on it.
5. **Audit retention** runs two passes until ~2027-01: RowKey-range for `!`-rows plus a
`RowKey ge '0' and Timestamp lt …` pass that ages legacy GUID rows out via their frozen
migration-date system Timestamp (remove after 2027-03-01).
# Consequences
* Audit legacy GUID rows have "unknown date" semantics: excluded from date-filtered
views, shown with the (wrong but honest) 2026-07-18 fallback in unfiltered views.
* Raw endpoints (`/api/raw/*`, `query_table`) intentionally keep showing the raw system
Timestamp — they are storage-state inspectors, not business views.
* Event timestamps for pre-cutover rows resolve to the RowKey's sanitized agent time
(ms precision); `ReceivedAt` (server ingest time, rows since 2026-03-19) and
`OriginalTimestamp` (pre-clamp agent time, clamped rows only) remain separate fields.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/BusinessTimestamp.cs` — decoders + clause builders
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs` — audit write/mapper/filters/retention
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs` — ops write/mapper/filters/retention
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — event writes, `ResolveEventTimestamp`, earliest-event read
* `src/Backend/AutopilotMonitor.Functions/Services/OccurredUtcBackfillService.cs` + `Functions/Admin/BackfillOccurredUtcFunction.cs` — backfill
+142
View File
@@ -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`
+122
View File
@@ -0,0 +1,122 @@
---
type: Concept
title: Device Journeys & First-Time-Right (F2)
description: How a device's terminal enrollment attempts are grouped into journeys (device key, junk-serial exclusion, first-success end, 30-day gap), where the DeviceHistories chain and the daily FTR aggregates are computed, stored and pruned, and which surfaces serve them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs
tags:
- backend
- metrics
- device-history
- first-time-right
- insights
timestamp: 2026-07-27T12:00:00+02:00
---
# Device Journeys & First-Time-Right (F2)
Answers "how many devices enrolled right on the first try?" and "show me every attempt
this device made". Session success rate hides wipe-and-retry loops — every retry that
eventually succeeds counts as a success; First-Time-Right (FTR) exposes the real pain.
All grouping semantics live server-side in `DeviceJourneyCalculator` (JourneyVersion 1);
no consumer re-derives them.
# Schema
## Device key
`(TenantId, normalized serial)` — normalization is trim + lower-case. Junk/placeholder
serials are excluded entirely (never a chain, never in FTR, disclosed as a per-day
exclusion count): `System Serial Number`, `To Be Filled By O.E.M.`, `Default string`,
`0`, `None`, `INVALID`, `Unknown` (the agent's WMI-failure sentinel), empty, or shorter
than 4 characters. A motherboard swap changes the serial and therefore the device key;
re-enrollment under a different tenant is a separate key by design.
## Journey
Terminal sessions of a device key ordered by StartedAt. The terminal set is
**Succeeded / Failed / Incomplete** — `Pending` (a sealed WhiteGlove part 1),
`AwaitingUser`, `Stalled` and `InProgress` are OPEN sessions and never attempts.
* A journey **ends** with the first terminal success; the next session starts a new
journey (redeployment).
* A gap of **more than 30 days** since the previous attempt's terminal moment
(`CompletedAt`, fallback StartedAt) starts a new journey even without a success
(device shelved/repurposed). Constant, not a setting — production gaps: median 4.9 h,
92 % < 7 d.
* WhiteGlove part 1 + part 2 share ONE session row, so a completed WG enrollment is ONE
attempt; a WG device still waiting for its user session leaves the journey open —
never a failed attempt.
* **Attempt count** = terminal sessions in the journey (`Failed` and `Incomplete` are
non-successful attempts).
* **FTR rate** = completed journeys with attempt count 1 ÷ all completed journeys.
Open and gap-abandoned journeys never enter numerator or denominator.
Durations shown for attempts are the sessions' authoritative `DurationSeconds` verbatim
(WhiteGlove pause excluded; `Incomplete` deliberately has none) — never
`CompletedAt − StartedAt`, which is later in ~25 % of terminal sessions.
## Persistence & compute
* `DeviceHistories` (PK = TenantId, RK = percent-encoded normalized serial): the chain of
the device's terminal session refs `{sessionId, startedAt, completedAt, status,
enrollmentType, isPreProvisioned, durationSeconds, adminMarked}` capped at the 20 most
recent, plus derived `CurrentJourneyAttempts` (the LAST journey's attempts — the
banner's "Attempt N"), `JourneyCount` (chain-scoped) and `JourneyVersion`. Written
inline at the session-terminal single-writer seam
(`TableSessionRepository.UpdateSessionStatusAsync`, all three terminal statuses,
DeletionState-guarded) so the session banner is fresh; healed by the maintenance sweep.
Deliberately NOT part of the per-session deletion manifest — the row aggregates many
sessions. Wiped on tenant offboarding.
* Sweep (`SweepDeviceJourneysAsync`, 30-day rolling window, 2h tick): (1) drops chain
refs of deleted sessions **tombstone-driven** — every session deletion (cascade and
retention both run through the cascade worker) leaves a `SessionTombstones` marker for
~7 days, far longer than the sweep cadence, and even an expired-but-unpruned marker is
valid deletion evidence; a chain left empty deletes its row. (2) Merges the window's
terminal sessions into their chains (backfill, inline-miss healing, re-terminal
reclassifications). (3) Recomputes the daily FTR aggregates idempotently.
* `DeviceJourneyAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}`): per day —
completed journeys, first-time-right count, attempt histogram, junk-serial exclusion
count, `JourneyVersion`. A journey buckets on the StartedAt date of its completing
success session (the platform's StartedAt-date convention; late terminals converge via
the sweep). All counts are **additive**, so a window rate is the ratio of summed daily
rows — there is no rolling-window row (unlike the median-based time-attribution
aggregates, which cannot merge). 180-day retention. Rows exist below the ≥20 UI gate
on purpose (the UI needs the n for "insufficient data (n=…)").
# Surfaces
* `GET api/metrics/device-history?serialNumber=&sessionId=` (MemberRead + tenant query
param — the same route serves the web banner and MCP; a Global Admin passes `tenantId`)
— the chain plus, with `sessionId`, that session's server-computed attempt number
(live sessions get their would-be position via a virtual non-successful attempt, so the
redeploy and gap rules apply). `history: null` = no recorded history.
* `GET api/metrics/device-journeys?days=` (MemberRead) /
`GET api/global/metrics/device-journeys?days=&tenantId=` (GlobalReadOrAdmin) —
`{ windowDays, totals, daily, repeatDevices }`. The days selector is honored because
counts are additive. Without `tenantId` the global variant serves the cross-tenant
aggregate rows and `repeatDevices: null` — a per-device list would require scanning
every tenant's partition.
* Repeat devices (violator list, capped 10): devices whose LAST journey took ≥2 attempts
and whose newest terminal session falls inside the window, ordered by attempts then
recency; the failure reason comes from the newest failed attempt (bounded point-reads).
* Web: "Attempt N for this device · View history" banner on session detail (expandable
chain); "First-time-right" section on Fleet Health (rate + weekly trend + attempt
histogram + repeat devices). MCP: `get_device_history` (serial mode / fleet FTR mode).
# Examples
A device fails ESP twice and succeeds on the third try within one week: one journey,
three attempts, completed — counts once in the FTR denominator, not in the numerator,
and the histogram's bucket `3` gains one. The same device redeployed a month later and
succeeding immediately starts journey 2 with attempt count 1 — first-time-right. A
device with `serialNumber = "Unknown"` (WMI failure) never gets a chain; its terminal
sessions surface only as that day's disclosed exclusion count.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/DeviceJourneyCalculator.cs` — device key, junk list, grouping, attempt numbers (JourneyVersion 1).
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.DeviceHistory.cs` — persistence + inline seam entry.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.DeviceJourneys.cs` — sweep, tombstone pruning, daily aggregation.
* `src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs` — read surfaces.
* `src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs` — table-driven grouping/aggregation/attempt-number tests.
* `tasks/insights-expansion-spec.md` — F2 specification and source-data audit (§0.5).
+107
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
---
type: Concept
title: Rule Regression Radar (F3)
description: How the radar detects analyze rules whose hit rate suddenly rose (7-day window vs 28-day baseline, Wilson-separated), which suppressions keep it quiet, how alert episodes live in the notification tracker, and which surfaces show them.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs
tags:
- backend
- rules
- regression-radar
- insights
timestamp: 2026-07-27T16:00:00+02:00
---
# Rule Regression Radar (F3)
Detects "this analyze rule suddenly fires much more often" before helpdesk volume does, and
says what the spike correlates with — with honest statistics and honest wording. Analyze
rules only in v1: their daily stats are audit-verified session-deduplicated
(`FireCount` ≈ distinct sessions with a hit, `SessionsEvaluated` the same-source
denominator). Gather rules are excluded until their per-batch stats dedup is fixed —
never silently included with bad math.
# Schema
## Signal & detection
Per (tenant, ruleId), daily hit rate = `FireCount ÷ SessionsEvaluated` from the
`RuleStats` rows — RATES, not counts (absolute counts spike with every rollout wave).
A regression fires only when ALL hold for the trailing 7-day window vs. the prior
28-day baseline:
1. window has ≥ 5 hit sessions AND ≥ 20 evaluated sessions;
2. window rate ≥ 2× baseline rate;
3. the Wilson 95 % intervals are disjoint in the increase direction
(`MetricsMath.WilsonInterval` / `RateIncreaseSeparated` — deterministic, unit-pinned),
so small-n noise never alerts.
## Suppression (false positives are trust damage)
* Rule entity missing (deleted) — never alerts.
* Rule younger than baseline + window (`CreatedAt` within 35 days — grace period for new
rules; the entity timestamp is the honest gate, a stats-row count would fail on
low-volume tenants whose quiet days simply have no rows).
* Rule edited inside the window (`UpdatedAt` ≥ window start) — edits legitimately change
hit rates.
* Empty baseline denominator (rule disabled throughout the baseline).
* Platform kill switch: app setting `RuleRegressionRadarDisabled=true` skips the radar
(fail-open — it only notifies, never mutates data).
## Alert episodes (tracker keyspace)
An episode lives as `ruleregression|{ruleId}` in the notification-tracker table
(the `tpmpss|` pattern): the row IS the dedup (bell + ops event fire exactly once per
episode), the badge state and the `regressions[]` payload — window/baseline counts,
rates, lift (null for a zero-baseline "new signal" — no finite lift is invented), window
dates, and the dimension concentration captured at first fire. Numbers refresh on every
radar pass while the episode stays active; `FirstNotifiedAt` never moves. The episode
closes (badge clears, dedup re-arms) when the window rate falls back under 1.5× baseline
or fires stop entirely; the tracker's 30-day retention sweep also re-arms, so a
month-old still-burning regression rings once more by design.
## Dimension correlation (on fire only)
Hit sessions (RuleResults scan: tenant partition range + RowKey = ruleId + DetectedAt in
window, intersected with the window's sessions) are compared against all window sessions
across `osBuild`, `model` (manufacturer + model), `agentVersion` and `imeVersion`. The
top value by lift is reported with BOTH shares — wording contract: "X % of affected
sessions are on … vs Y % of all sessions — correlated, not necessarily causal". No value
with ≥ 5 hit sessions at ≥ 2× lift → "no clear dimension concentration"; a correlation
failure also yields no claim. App-version correlation for `ANALYZE-APP-*` rules is a
follow-up (needs rule→app matching semantics), tracked with the gather-rule fix.
## Compute & alert path
Runs in the daily maintenance pass right after the rule-stats aggregation
(`RunRuleRegressionRadarAsync`), anchored on YESTERDAY — whole days only, a partial
today would understate the window rate. One 35-day stats read covers all tenants;
evaluation is the pure `RuleRegressionRadar` core. On a NEW episode:
1. `RuleFrequencyRegression` ops event (category Tenant, Warning) with the full numbers
— dual-registered in the web `OPS_EVENT_TYPES` catalog for alert-rule routing.
2. Tenant bell notification (type `rule_frequency_regression`, Admin audience) with the
same numbers and a deep link to the rule's card (`/analyze-rules#rule-card-{ruleId}`).
A sessions-list pre-filtered to rule hits needs a rule filter the session search does
not have yet — follow-up alongside the gather-rule work.
3. v1 channel: bell + ops event only. Provider fan-out (webhook/Telegram) follows once
alert quality is proven — a noisy first version burns trust permanently.
# Surfaces
* `GET api/metrics/rule-stats` (and the global variant with `tenantId`) carries
`regressions[]` — the active episodes. MCP `get_rule_stats` inherits the block.
* Analyze-rules page: per-rule 30-day fire-count sparkline (densified from the `trend`
rows the endpoint already served) and a red "↑ Regression" badge while an episode is
active, with the full numbers in the tooltip.
# Examples
A rule firing on 2 % of sessions for a month jumps to 15 % over a week (15/100 vs
10/500): lift 7.5×, Wilson-separated → one bell, one ops event, badge until the rate
falls back under 3 %. The same jump on 5/20 sessions with a 12 % baseline stays silent —
the intervals overlap, so the spike is not statistically real yet. A rule that never
fired in the baseline and now hits 15 % alerts as a "new signal" with no lift figure.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/RuleRegressionRadar.cs` — gates, suppression, re-arm, dimension concentration.
* `src/Backend/AutopilotMonitor.Functions/Helpers/MetricsMath.cs` — Wilson interval + separation primitive.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.RuleRegression.cs` — sweep, episode reconcile, bell/ops wiring.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs` — episode keyspace.
* `src/Backend/AutopilotMonitor.Functions.Tests/RuleRegressionRadarTests.cs` — hand-computed Wilson vectors + every suppression branch.
* `tasks/insights-expansion-spec.md` — F3 specification and source-data audit (§0.5).
@@ -0,0 +1,102 @@
---
type: Concept
title: ServerActions & On-Demand Diagnostics Collection
description: The queue-on-session, deliver-on-ingest ServerAction channel (terminate_session / rotate_config / request_diagnostics), the operator-tier authorization split, the portal's Collect Logs flow including the admin quick-config path, and why the offboarding cascade sweeps the hosted diagnostics prefix.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs
tags:
- backend
- agent
- web
- server-actions
- diagnostics
- authorization
timestamp: 2026-07-24T00:00:00+02:00
---
# Schema
**Channel.** A `ServerAction` (`Type`, `Reason`, `RuleId`, `Params`, `QueuedAt`) is queued onto
`Sessions.PendingActionsJson` by server-side writers — the admin endpoint
`POST /api/sessions/{sessionId}/actions`, the RuleEngine, maintenance — and delivered
piggy-backed on the agent's next ingest response. No polling channel exists; delivery latency
equals the agent's telemetry cadence (seconds to ~1 minute during enrollment). Delivery is
at-least-once: the agent's `ServerActionDispatcher` dedups on `Type|RuleId|QueuedAt` and
processes a batch sequentially in queue order. Unknown types are logged and skipped, so new
types roll out server-first. Every action emits `server_action_received` and then
`server_action_executed` or `server_action_failed` into the session timeline.
**Authorization (two layers).** The route sits on `EndpointPolicy.TenantAdminOrOperator`
(tenant Admin, tenant Operator, or Global Admin — no Viewer, no Global Reader: it is a write
tier). Because the route serves multiple action kinds, `QueueSessionActionFunction`
re-gates per type via `IsTypeAllowedForCaller`: Operators may queue only
`request_diagnostics`; `terminate_session` and `rotate_config` require
`RequestContext.IsTenantAdmin || IsGlobalAdmin` (403 otherwise). Any route on this tier with
a multi-kind body MUST follow this same in-function re-gating pattern.
**On-demand collection (`request_diagnostics`).** The dispatcher calls
`DiagnosticsPackageService.CreateAndUploadAsync(enrollmentSucceeded: false, suffix:
"server-requested")` — producing `AgentDiagnostics-{sessionId}-{timestamp}-server-requested.zip`.
Passing `enrollmentSucceeded: false` is what makes on-demand collection work under
`DiagnosticsUploadMode=OnFailure`; only `Off` (or an unconfigured destination) blocks it,
surfacing as `server_action_failed`. The `diagnostics_uploaded` event stamps
`DiagnosticsBlobName` + `Destination` onto the Sessions row (latest wins — an on-demand name
is later replaced by the terminal package), and the SignalR `sessionUpdate` delta carries the
field, so the portal's Download button flips live.
**Portal flow (Collect Logs, session detail header).** The button is always rendered; the
disabled states carry tooltips (inactive session, cross-tenant view, missing role, diagnostics
not configured) as a deliberate nudge. Members below Admin cannot read the tenant config, so
the member-readable feature-flags endpoint exposes `diagnosticsUploadConfigured`
(`mode != Off && (customer SAS present || destination Hosted)`). Progress tracking is
sequence-based (backend-assigned, monotonic), never timestamp-based — agent clock skew and
at-least-once redelivery of older actions cannot produce false matches
(`collectLogsLogic.ts`). Cross-tenant views are gated off because `TenantHelper.GetTenantId`
resolves the tenant strictly from the caller's JWT.
**Admin quick-config.** For an unconfigured tenant, a Tenant Admin's click opens a dialog
that switches the tenant to `Destination=Hosted`, `Mode=OnFailure` (GET full config → patch
exactly those two fields → PUT verbatim), then queues `rotate_config` **before**
`request_diagnostics` — the dispatcher's sequential ordering guarantees the agent refetches
the now-enabled config (the `RemoteConfigMerger` applies diagnostics fields live, remote wins)
before building the package. This works against the already-deployed agent fleet; a
"one-time override" variant would not, because the `mode=Off` check is client-side in
`CreateAndUploadAsync` and the deployed handler reads no params.
**Offboarding cascade.** Latest-wins on `DiagnosticsBlobName` means a session can leave more
hosted blobs behind than the deletion manifest references. The cascade's Hosted branch
therefore sweeps the canonical prefix `{tenantId}/AgentDiagnostics-{sessionId}-`
(`HostedDiagnosticsBlobService.DeleteBySessionPrefixAsync`) plus the manifest-named blob as
belt-and-braces; a non-GUID legacy `SessionId` degrades to the single-name delete instead of
poisoning the cascade. The CustomerSas branch is unchanged (delete only with the `d`
permission; the customer's lifecycle rules stay authoritative).
# Examples
Queue an on-demand collection (Admin or Operator, own tenant):
```
POST /api/sessions/{sessionId}/actions
{ "type": "request_diagnostics", "reason": "On-demand log collection from portal" }
→ 202 { "success": true, "queuedAt": "..." }
```
Operator queueing an admin-only type:
```
POST /api/sessions/{sessionId}/actions
{ "type": "terminate_session" }
→ 403 { "message": "Action type 'terminate_session' requires the Tenant Admin role" }
```
Timeline for a successful round trip: `server_action_received` → `server_action_executed`
(with `blobName`) → `diagnostics_uploaded` → Sessions row + SignalR delta carry the new
`DiagnosticsBlobName`.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs` — allowed types, per-type caller gate.
* `src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs` — `TenantAdminOrOperator` tier definition.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs` — dedup, sequential dispatch, telemetry contract.
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — mode gates, archive build, suffix.
* `src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs` — hosted prefix sweep.
* `src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/collectLogsLogic.ts` — button gating matrix, sequence-based progress evaluation.
+122
View File
@@ -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
+124
View File
@@ -0,0 +1,124 @@
---
type: Concept
title: Enrollment Time Attribution (F1)
description: How a terminal session's wall clock is partitioned into named segments with an explicit unattributed remainder, how ESP-blocking apps and reboots are attributed, and where breakdowns and fleet aggregates are computed, stored and served.
resource: src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs
tags:
- backend
- metrics
- time-attribution
- insights
timestamp: 2026-07-26T20:00:00+02:00
---
# Enrollment Time Attribution (F1)
Answers "where did the 47 minutes go?" per session and per fleet. Every number is
traceable to observed events; a missing signal produces "unknown" or an explicit
unattributed remainder — never a guess, and the partition is never normalized to 100 %.
# Schema
## Invariant
For every computed breakdown:
```
sum(span seconds) + UnattributedSeconds == WallClockSeconds == session DurationSeconds
```
`DurationSeconds` is the authoritative wall clock — for WhiteGlove it is part 1 + part 2
WITHOUT the pause, and re-terminal stamps move `CompletedAt`, so `CompletedAt − StartedAt`
is never used (it diverges in ~25 % of terminal sessions). Span seconds are floored;
sub-second dust lands in the unattributed remainder.
## Observation windows
Windows are end-anchored on authoritative timestamps and constructed so their lengths sum
exactly to `DurationSeconds`:
* Non-WhiteGlove: one window `[CompletedAt − DurationSeconds, CompletedAt]`.
* WhiteGlove (ONE session row, two windows): part 2 = `[ResumedAt, CompletedAt]`; part 1 =
`[part1End − (DurationSeconds − part2), part1End]` where `part1End` is anchored on the
`whiteglove_part1_complete` event. The pause between the windows is never attributed.
Fallback anchors set the `WhiteGloveAnchorsIncomplete` quality flag.
## Segments
Spans are sliced at phase-declaration events (only declaration events carry
`Phase != Unknown`) plus `desktop_arrived`, in canonical Sequence order; anchors whose
timestamp runs backward are dropped (`ClockSkewDropped`).
| EnrollmentPhase | Segment key |
|---|---|
| Start / DevicePreparation / DeviceSetup | `device_prep` |
| AppsDevice | `esp_apps` |
| AccountSetup / FinalizingSetup | `identity_hello` |
| AppsUser | `user_esp` |
| Complete, `desktop_arrived` event | `desktop_handoff` |
| Failed | attribution ends — tail stays unattributed |
## ESP-blocking apps (positive evidence only)
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
event, covering IME retries) — never the agent payload timing, which freezes at the first
terminal transition. Blocking membership joins the app id against the session's latest
list-carrying `esp_config_detected` emission: listed ⇒ blocking; absent ⇒ unknown, never
false (`BlockingSetUnknown` / `BlockingSetTruncated` flags). Critical-path occupancy =
overlap-merged union of blocking intervals clipped to the `esp_apps` spans;
`EspAppsOccupancySeconds` is null (unknown) when no lists were observed.
The fleet what-if bound per app X is `max(0, cpEnd − cpEndWithoutX)` (latest interval end
with vs. without X; the idle gap before X counts — the ESP also waits for X to start).
It is an upper bound by construction; every consumer says "up to", never "you will save".
## Reboots
`system_reboot_detected` is detection-time-stamped by the NEXT agent run; the real boot
moment is its `lastBootUtc` payload. Reboot spans are therefore the event-stream gap
bracketing `lastBootUtc`, deduped per boot, clipped to the observation windows, and carried
as a cross-cutting annotation (they overlap the segment they started in — never a slice of
the partition).
## Persistence & compute
* `SessionTimeBreakdowns` (PK = TenantId, RK = SessionId): spans, reboot spans, blocking
intervals (top 20 + uncapped count), quality flags, `AttributionVersion`. Written once at
the session-terminal single-writer seam (`TableSessionRepository.UpdateSessionStatusAsync`,
Succeeded/Failed only — Incomplete deliberately has no duration). Deleted with its session
(deletion-manifest step) and on tenant offboarding.
* Self-healing 30-day maintenance sweep (2h tick): backfills missing breakdowns, folds
late-terminating sessions into their StartedAt-date aggregates, and enforces version
purity — a stale-version row whose events aged out counts as missing, never mixed.
* `TimeAttributionAggregates` (PK = TenantId or `global`, RK = `{yyyy-MM-dd}|{class}` and
`rolling30|{class}`): per enrollment class (`user_driven`, `whiteglove`, `self_deploying`,
`device_preparation` — never mixed) median/p75/p90 per segment over the fixed six-segment
stack (incl. `unattributed`; a session without a segment contributes 0), plus top blocking
apps (≥5 sessions per app row). Clean/flagged/missing counts are part of every row; rows
exist below the ≥20 UI gate on purpose (the UI needs the n for "insufficient data (n=…)").
Daily rows retain 180 days; `rolling30` rows are refreshed whole (a median of per-day
medians is not the range median — the rolling rows are the honest range statistics).
## Surfaces
* `GET api/sessions/{sessionId}/time-attribution` (MemberRead + tenant query param) —
breakdown row or `breakdown: null` (pre-feature / non-terminal / Incomplete = no lane).
* `GET api/metrics/time-attribution` (MemberRead) / `GET api/global/metrics/time-attribution`
(GlobalReadOrAdmin, optional `tenantId`) — `{ windowDays, classes (rolling), daily }`.
* Web: attribution lane under the session-detail phase timeline; "Time attribution" section
on Fleet Health. MCP: `get_time_attribution` (sessionId mode / fleet mode).
# Examples
A 30-minute user-driven session: `device_prep` 5m → `esp_apps` 10m (occupancy 4m by one
blocking app) → `identity_hello` 8m → `user_esp` 5m → `desktop_handoff` 2m, unattributed 0.
A WhiteGlove session with a 2-day pause reports wall clock = part1 + part2 only; the resume
gap before the first part-2 phase declaration is unattributed, not guessed.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
* `tasks/insights-expansion-spec.md` — F1 specification and source-data audit (§0.5).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+31 -1
View File
@@ -12,16 +12,46 @@ https://docs.autopilotmonitor.com (separate repository).
# Architecture
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [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 & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval 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
* [Portal Navigation Prefetch & the SWA Runtime Bottleneck](web/portal-navigation-prefetch.md) - Why every navigation Link sets `prefetch={false}`: the App Router's viewport prefetch fans out one RSC request per visible link and the Static Web Apps SSR runtime queues them server-side into a multi-minute tail. Records the follow-up measurement (the Standard plan changed nothing; the prefetch storm was an accidental keep-alive), where browser telemetry actually lives, and why this curl build cannot be trusted for HTTP versions.
# Security & Identity
+121
View File
@@ -1,13 +1,134 @@
# Log
## 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.
* **Creation**: Added `agent/gather-rule-debug-log.md` — the new per-tenant setting `EnableGatherRuleDebugLog` (ConfigVersion 35 → 36, portal toggle under Settings → Agent, `EnableImeMatchLog`-style bool→path projection in `RemoteConfigMerger`). When enabled, the agent writes `%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`: one pipe-delimited line per evaluation decision across ten previously silent "produced nothing" exits — disabled rules, `interval` without `intervalSeconds` (never runs), the first-tick-after-one-full-interval window, phase-scope skips incl. `currentPhase=Unknown`, per-(rule, phase) dedup, empty collector results (`emitOnlyIfExists`), `on_change` hash suppression (hash prefix + streak), guard blocks, logparser per-file outcomes (position skips, parse failures with a `format=text` hint when every line fails CMTrace parsing, `maxLines`/wildcard caps), and full exception stacks that the agent log drops. 10 MB cap + one `.old` generation; no hot-reload (per-enrollment lifecycle); flag survives the offline config cache; `--run-gather-rules` always traces and echoes under `--console`; zero-rules delivery leaves a one-line file instead of nothing. Side fix: `GatherRuleExecutorHost` now passes the logger into `InformationalEventPost`, so gather-event emissions appear in the agent DEBUG log.
* **Correction + Update**: `web/portal-navigation-prefetch.md`. The HTTP/1.1 claim in the 2026-07-27 entry below is **wrong** and has been struck from the document: `portal.autopilotmonitor.com` negotiates **h2**, verified by `openssl s_client -alpn h2,http/1.1` and a .NET `HttpClient` returning `HTTP/2.0`. The false reading came from the Windows/Schannel curl build in this environment, which has no HTTP/2 support and therefore always reports `1.1` — do not use it to determine HTTP versions. There is no six-connection cap in play; the queue is server-side in the SWA SSR runtime, which does not change the fix or the evidence, only the mechanism. Added an `Outcome` section with 20 h of post-change telemetry: the Free → Standard plan change (16:02:44 UTC) produced **no measurable latency gain** once stratified by the gap to the previous request (p50 Free → Standard: `<10s` 85 → 95 ms, `5-30min` 429 → 441 ms; tail rates 1.64 %/0.374 % over 2 s/10 s vs 6 and 1 events at n = 258 against 4.2 and 1.0 expected; `browserTimings` Mann-Whitney z = −0.24, p = 0.81). The freezes were removed by `prefetch={false}` (16:34:13 UTC): worst `browserTimings.totalDuration` 225,277 → 4,856 ms. The prefetch storm was also an **accidental keep-alive** — requests under 10 s apart fell from 85 % to 46 %, and on the identical never-cacheable `/sessions/{id}` route the median rose 87 → 211 ms and p90 465 → 1,480 ms, with one 11,441 ms abandoned request (`resultCode 0`) after 517 minutes idle. Synthetic bursts warm the runtime and must not be used to benchmark this (round 1 p50 288 ms vs rounds 4–25 p50 113 ms).
## 2026-07-27
* **Creation**: Added `web/portal-navigation-prefetch.md` (and a `Web Portal` section to the index) — root cause of the minute-long portal freezes. `GlobalSidebar` and `Navbar` render the whole navigation on every page, so the App Router's default viewport prefetch issued one `?_rsc=` request per visible link against `portal.autopilotmonitor.com` (~10,400 in 14 days). *(The original entry attributed the freeze to HTTP/1.1 and a six-connection-per-origin cap — see the 2026-07-28 correction; the queue is server-side.)* When the Static Web Apps managed runtime queued the prefetches (p50 91 ms, p95 896 ms, but max 234 s / 21 samples over 30 s), the navigation the user wanted was stuck behind that queue. Measured on the 2026-07-27 17:25 CEST incident: `/admin/presence?_rsc=` 151 s, three siblings finishing within 340 ms of each other at ~37.5 s, all `200`, while the backend API answered in 1.6–2.2 s and `browserTimings` showed `networkDuration` 16 ms against `processingDuration` 225 s. Every navigation Link now sets `prefetch={false}` — these routes are client components behind `ProtectedRoute` that fetch their own data, so the prefetched RSC payload carried no benefit. The doc also records that browser telemetry lives in a **separate** App Insights resource (`AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`) that the MCP `query_backend_logs` tool cannot reach, which is why the freezes left no trace in the backend logs.
* **Creation**: Added `backend/rule-regression-radar.md` — F3 regression radar (analyze rules only; gather rules wait for their per-batch stats dedup fix): per (tenant, rule) daily hit rate from `RuleStats`, firing only when the trailing 7-day window has ≥5 hit sessions and ≥20 evaluated sessions, the rate is ≥2× the prior 28-day baseline AND the Wilson 95 % intervals are disjoint (deterministic `MetricsMath` primitive with hand-computed test vectors). Suppressed for deleted rules, rules younger than baseline+window (`CreatedAt` grace), rules edited inside the window (`UpdatedAt`), empty baselines, and the `RuleRegressionRadarDisabled` app-setting kill switch. Alert episodes live as `ruleregression|{ruleId}` tracker rows (one bell + one `RuleFrequencyRegression` ops event per episode; numbers refreshed while active; re-armed under 1.5× baseline, on fire-stop, or by the 30-day tracker retention). On-fire dimension correlation over osBuild/model/agentVersion/imeVersion with the "correlated, not necessarily causal" wording contract. Surfaces: `regressions[]` in the rule-stats response (MCP inherits), the analyze-rules page's 30-day sparkline and regression badge.
* **Creation**: Added `backend/device-journeys.md` — F2 device history / First-Time-Right: the device key (trim + case-fold serial, junk/placeholder exclusion incl. the agent's `Unknown` WMI sentinel), journey grouping over the terminal set Succeeded/Failed/Incomplete (first success ends a journey, a >30-day gap from the previous attempt's CompletedAt starts one, WhiteGlove's single session row is ONE attempt, open sessions are never attempts), the `DeviceHistories` chain (cap 20, written inline at the terminal seam, healed by the sweep, deleted-session refs pruned tombstone-driven, deliberately not in the per-session deletion manifest) and the additive `DeviceJourneyAggregates` daily rows (window rate = ratio of sums — no rolling row needed, 180-day retention). Surfaces: the session-detail "Attempt N" banner with server-computed attempt numbers (virtual-attempt rule for live sessions), the Fleet Health First-time-right section (≥20-journey gate, attempt histogram, repeat-devices violator list), and the MCP `get_device_history` tool (serial mode / fleet FTR mode).
## 2026-07-25
* **Update**: `agent/secureboot-ca2023-detection.md` — review follow-up on 90012d9 hardened the privilege scope, the repo's only privilege-adjustment code and therefore the precedent for any future one. The process token moved from a raw `IntPtr` to a `SafeHandle` assigned by the P/Invoke marshaler, so the CLR owns it from the moment `OpenProcessToken` returns and the critical finalizer releases it even when `Dispose` never runs — previously an async exception between the successful open and the caller's `using` leaked the handle *with the privilege enabled and no owner left to restore it*. `Dispose` became idempotent and now releases the handle in a `finally`, so a throwing restore cannot strand it; the restore obligation is recorded immediately after the successful `AdjustTokenPrivileges` with nothing throwable in between. The whole enable → read → restore sequence runs under a static gate because the privilege is adjusted on the *process* token: a concurrent reader would see "already enabled", correctly skip its own restore, and then lose the privilege mid-read when the first reader disposed, reporting `privilege_denied` on a machine that holds it.
## 2026-07-24
* **Creation**: Added `backend/server-actions-on-demand-diagnostics.md` — the ServerAction channel (queue on `Sessions.PendingActionsJson`, deliver piggy-backed on the next ingest response, at-least-once with `Type|RuleId|QueuedAt` dedup and sequential dispatch) and the on-demand log collection built on it: the portal's always-visible **Collect Logs** button, the new `EndpointPolicy.TenantAdminOrOperator` tier with the mandatory in-function per-type re-gate (Operators queue only `request_diagnostics`), the member-readable `diagnosticsUploadConfigured` feature flag, the admin quick-config path that must queue `rotate_config` before `request_diagnostics` (works against the deployed fleet — a one-time override would not, the mode gate is agent-side), sequence-based progress matching immune to agent clock skew, and the offboarding cascade's hosted-prefix sweep closing the latest-wins orphan-blob gap.
* **Update**: `agent/esp-policy-provider-stall.md` — the customer's follow-up on issue #106 corrected the field-case mechanics: `TrackingPoliciesCreated=1` WAS set under the foreign `ConfigMgr` provider, so the ESP ignores a foreign provider's completion values and waits on the Intune-registered `Sidecar` provider by name (the rename to `Sidecar` creates no tracking entries yet unblocks instantly). The completeness-keyed detector would have missed exactly this — the incident it was built for. The tripwire is now two-armed under one event type discriminated by `reason`: `provider_incomplete` (unchanged dwell on continuously-incomplete providers) and `sidecar_provider_missing` (≥ 1 Setup/Apps provider registered without `Sidecar`, completeness deliberately ignored, clock reset when Sidecar registers later). Sidecar-missing wins the `reason` when both arms cross in one round; the cross-restart fingerprint gains a `sidecarMissing` sentinel; payload adds `reason` and `sidecarMissingForMinutes`. Evidence for the name-keyed wait: Niehaus' documented Intune WAP payload always registers `Sidecar` (ConfigMgr only via a co-management settings policy) and Microsoft's ESP troubleshooting models app tracking exclusively through the Sidecar subkey.
* **Creation**: Added `agent/esp-policy-provider-stall.md` — a customer diagnosed that pre-provisioned devices with the ConfigMgr client as a blocking app occasionally park the user ESP at "Apps (Identifying)" for days: only a `ConfigMgr` provider is registered under `EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders` (no `Sidecar` = IME) and `TrackingPoliciesCreated` never becomes 1 — a wait the CSP defines without any timeout; renaming the key to `Sidecar` unblocks immediately. The agent now ships the always-on `esp_policy_provider_stalled` tripwire (60-s tick, 15-min continuous-incompleteness dwell, one-shot per provider, `StartupEventGate` fingerprint across restarts, purely observational via the InformationalEvent rail — no toggle, no ConfigVersion bump), generalized to any provider in both `Setup/Apps` and `DevicePreparation` kinds rather than hardcoding the ConfigMgr case. Companion change: `SOFTWARE\Microsoft\Windows\Autopilot` joins the gather-rule registry allowlist so tenants can target the subtree with custom rules (e.g. `not_exists` on the Sidecar key).
* **Creation**: Added `agent/secureboot-ca2023-detection.md` — ANALYZE-SEC-001 warned on devices whose firmware already carried the 2023 certificate, because the agent only read the `SecureBoot\Servicing\UEFICA2023Status` registry value, which mirrors the Windows Update rollout rather than the firmware (customer report). The agent now reads the UEFI `db`/`KEK` variables natively (`GetFirmwareEnvironmentVariableExW` with `SeSystemEnvironmentPrivilege` enabled on the process token — first privilege-adjustment code in the repo) and emits per-certificate booleans on `secureboot_status`. Suppression rides on a one-sided marker, `uefiCa2023FirmwareConfirmed`, emitted only on positive confirmation: the engine's `not_exists` precondition fails on any present value, so a two-sided boolean could not distinguish "confirmed" from "checked and missing", while the marker's absence keeps old agents and firmware-missing devices on the v2 path. Rule v3.0.0 is therefore safe to seed before the agent release.
* **Update**: `url-registry.md` — the first release after the alias migration surfaced Front Door caching as a correctness hazard: the alias served the previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) after the origin had rotated, and since manifest and ZIP are independent cache entries, a stale manifest with a fresh ZIP fails the bootstrap SHA-256 check. Route caching on `/agent/*` is now disabled and every blob upload stamps `Cache-Control: no-cache` (CI `Send-Blob`, local `build.ps1`, `versions/*.json` manifests) — all agent-container blobs rotate in place, so nothing there is safely cacheable.
* **Update**: `agent/decision-engine.md` — the RealmJoin completion gate has a third terminal, `FirstDeploymentIncomplete`. Session 224b2087 showed why: an interactive logon 17 s after DeploymentPhase 101 makes RealmJoin reclassify the run as a secondary-user deployment, whose branch writes 200/210 and never 110 — the gate stayed closed until the 60-min hard timeout although Hello and desktop were long resolved. `realmjoin_phase_changed` is now a typed `RealmJoinPhaseChanged` signal, so `RealmJoinFacts.LastDeploymentPhase` is persisted across agent restarts (this also fixes every `realmjoin_timeout` event claiming "last phase: 0"); a transition out of 100/101 into 200/210 releases the gate with the `realmjoin_first_deployment_incomplete` Warning, and the `RealmJoinDetected` replay path evaluates the same rule for restarts that swallow the transition. A first observation of 210 deliberately does not release — session 6f1959c0 started at 210 and only deployed afterwards.
* **Creation**: Added `url-registry.md` — all well-known hosts (own subdomains, both blob accounts, API origin, Graph, Entra login) are now defined once per component (`Constants.cs`, web `utils/config.ts`, MCP `src/config.ts`) and referenced everywhere else; three guard tests fail the build on any literal outside the registries. Written because the EU cutover missed two hardcoded copies of the legacy blob host — `LatestVersionsService` (feeds the portal's version badge) and `ValidateBootstrapCodeFunction` — which kept working only through the fail-soft legacy mirror in `build-agent.yml`; those two plus the agent `SelfUpdater` now use the `download.autopilotmonitor.com` alias (verified serving all four artifacts first). The portal's bootstrap allow-list accepts alias AND legacy host during the transition, which imposes a deploy order: web before (or with) backend. The sweep also surfaced a fourth copy of the www/portal host pair in `middleware.ts` (now derived from the registry via `hostRouting.ts`) and a backend short-link built on the bare apex domain, which only worked through a registrar-level 301.
## 2026-07-22
* **Creation**: Added `versioning.md` — agent, backend and MCP now share one version shape (`<major>.<minor>.<build>` + 7-char commit + build timestamp) and one implementation of the build-number reservation (`.github/scripts/Request-BuildNumber.ps1`, previously copy-pasted between `build-agent.yml` and `build.ps1`). The backend had been pinned at a hand-written `1.0.0` since inception and now starts at `1.5.1000`; the MCP server's `package.json` version was bumped by hand and now starts at `1.5.300`, and it reports its own commit on `/health` instead of only the documentation bundle's. Both deploy workflows poll their health endpoint until it reports the build just deployed and publish `versions/<component>.json` only then, so a manifest that disagrees with the live endpoint means the instance drifted rather than the pipeline failing halfway. `agent/build-counter-blob.md` keeps the agent-specific parts and now points at the shared protocol; its counter URL was still the pre-cutover storage account.
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests` `[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
## 2026-07-21
* **Update**: `search_docs` and `search_knowledge` now state in their tool descriptions that queries must be in English. Both corpora and `all-MiniLM-L6-v2` are English-only, and the failure is total rather than gradual — a German question returns zero results, not weak ones. The caller is a language model, so translating the question before the call and answering in the user's language costs nothing; a multilingual embedding model would be the alternative but is much larger and would have to be measured against the eval harness first, since it changes English retrieval too.
* **Update**: Added `scripts/eval-docs-search.ts`, an offline retrieval evaluation harness (2465 queries in three classes: chunk headings as a regression detector, rare identifiers, and hand-written questions as the reality anchor), and used it to close a third `search_docs` gap found during the live test. The keyword fallback only fired when semantic search returned *fewer* than `topK`; a pasted identifier like `WOW6432Node` instead returned three chunks at ~0.29 that did not contain the word while the one chunk that did was never considered. It now also fires on a weak semantic head (<0.35), letting exact matches take the front while keeping one semantic result. The threshold is measured, not guessed: 0.35 fixes 335 queries and breaks 0 (identifier recall 66.2% → 81.1%), while 0.40 fixes more but starts demoting hand-written questions. The measurement also resized the problem — identifier queries were only succeeding 66.2% of the time, so the reported case was one of roughly 750.
* **Update**: Closed the two measured recall gaps in `search_docs` (`mcp/docs-corpus.md`). (1) **Token windows** — 87 of 324 chunks exceeded the model's 256-token context and 13% of all tokens never reached the ranking; a character budget cannot fix this (the corpus ranges 2.43–5.81 chars/token), so long text is now embedded as 250-token windows with 40 overlap and scored by its BEST window (`embeddings: number[][]`, max not mean). Token coverage 87% → 100%; "proxy configuration blocking app downloads" went from absent to 0.559 at rank 1; the ten reference queries kept byte-identical scores. Costs +31% vectors, index 2.63 → 3.12 MB. The rules corpus gained the same treatment (128 rules → 172 vectors). (2) **Keyword fallback** — when the semantic pass returns fewer than `topK`, results are topped up via literal `lexicalMatch` on distinctive query terms, requiring two matching terms for corroboration (one incidental substring match returned a page about LLM providers for a sourdough query). Fuzzy matching was measured and rejected: at a threshold low enough to surface "RealmJoin" it invents 5 hits for "SCEPman", which the corpus never mentions. Also sharpened the tool description with the measured context argument for preferring it over fetching the docs site.
* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `<details>` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load.
## 2026-07-20
* **Update**: `rules/gather-rule-phase-scoping.md` — the gather-rule form now shows the phase-scope and emit-mode controls only where they can change behavior (`supportsPhaseScope` / `supportsEmitMode`), nulls hidden-control state out of the payload, and rejects a scope mode selected but left unfilled. Offering all three "when" controls unconditionally allowed combinations that could only suppress a one-shot rule, plus a silent no-op state.
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
* **Creation**: Added `backend/business-timestamps.md` (new `backend/` section) — the 2026-07-18 migration reset every row's system Timestamp, exposing that AuditLogs/OpsEvents/Events had (unknowingly) displayed and filtered on it all along; documents the `OccurredUtc` column, the RowKey decoders and RowKey-range date/retention clauses in `BusinessTimestamp`, the GUID-guard trap, the audit/ops backfill endpoint, and the "system Timestamp is never authoritative" migration rule.
* **Creation**: Added `rules/gather-rule-phase-scoping.md` (new `rules/` section) — gather rules gain `activePhases` (run only during listed phases), `activeFromPhase` (sticky latch from a phase onwards, never via Failed) and `emitMode: "on_change"` (canonical-hash result dedup with `suppressedPolls` observability), ConfigVersion 34. Includes the prerequisite backend fix: the custom-rule toggle PUT no longer wipes the stored rule definition (partial-merge in `GatherRuleService.UpdateRuleAsync`).
## 2026-07-18
* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
## 2026-07-26
* **Creation**: Added `backend/time-attribution.md` — the F1 enrollment time attribution: exact-partition invariant against the session's authoritative DurationSeconds, two-window WhiteGlove model, segment mapping from phase-declaration events, positive-evidence ESP-blocking join with "up to" what-if bounds, reboot spans from event gaps around `lastBootUtc`, and the SessionTimeBreakdowns / TimeAttributionAggregates tables with their terminal-seam + maintenance-sweep compute paths and read surfaces (web lane, fleet panel, MCP `get_time_attribution`).
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
+305
View File
@@ -0,0 +1,305 @@
---
type: Concept
title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling
description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server.
resource: /src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts
tags:
- mcp
- search
- embeddings
- documentation
- build
timestamp: 2026-07-21T19:30:00+02:00
---
# Purpose
The MCP server answers two different kinds of question, and they need two
different corpora.
* *"Why did this enrollment fail?"* → analysis rules, gather rules, IME log
patterns. That is `search_knowledge`, backed by `knowledge-base.ts`.
* *"How do I deploy the agent?" / "Where is my data stored?"* → the published
customer documentation. That is `search_docs`, backed by `docs-corpus.ts`.
They are deliberately **separate indexes**. Merged into one, they would compete
for the same `topK` and dilute each other: a rules query would surface prose
pages, and a product question would surface rule definitions.
# Schema
## Corpus source
The corpus is the `autopilotmonitor-docs` repository — a public OKF bundle
published at `docs.autopilotmonitor.com`. It is **not** vendored into this repo.
The deploy workflow checks it out into `mcp-docs/` of the Docker build context;
the Dockerfile copies it to `/app/docs` in both the build and the final stage
(byte-identical, or the boot-time corpus hash rejects the precomputed index).
`SUMMARY.md` and `log.md` are skipped as navigation scaffolding. `index.md` is
kept — it is the documentation map and answers "what documentation exists" — but
gets no `url`, because the bundle deliberately keeps it out of `SUMMARY.md` and
GitBook therefore never renders it.
## Chunking
A whole page cannot be one document: `trust/security-faq.md` is 36 KB, while
MiniLM-L6-v2 truncates at ~256 tokens. An un-chunked page would silently embed
only its first paragraph.
Chunks are cut at **h2/h3 headings**, which in this bundle are also the semantic
boundaries — `security-faq.md` carries 37 h3s, one per question.
`<details><summary>Q</summary>` blocks (used by `troubleshooting/faq.md`) are
treated the same way, with the summary as the section title. Each chunk is
prefixed with a `Page Title › Section › Subsection` breadcrumb so a hit lifted
out of the middle of a page is self-describing to both the embedder and the
reader.
Two deliberate exceptions to the ~1500-character budget:
* **Markdown tables are never split.** A table broken across chunks leaves
`| --- |` rows with no header — unreadable in a result and meaningless to
embed. Tables carry real answers here (the role matrix, the settings
reference), so an over-long table is allowed to exceed the budget.
* **Unbroken lists split at line level.** The changelog pages are one bullet
list per release with no blank lines, so paragraph splitting cannot touch
them; left whole, everything past the model's window would be truncated away
and become unsearchable.
Short sections are **kept**, not filtered. A one-line answer ("Is it free?" →
"Yes, the Community plan is free") is often the most valuable hit, and the
breadcrumb supplies the context the short body lacks. Only structural residue
(under 25 alphanumeric characters) is dropped.
## CRLF is a correctness precondition
The docs repo is authored on Windows and ships CRLF. In JavaScript `.` does not
match `\r`, so a pattern like `/^(#{2,3})\s+(.*)$/` matches **nothing** on a CRLF
file: `(.*)` stops before the `\r` and `$` never reaches end-of-string.
This was observed during development, and the failure mode is what makes it
dangerous: no crash, no error — headings and frontmatter simply stop being
recognized and every page silently degrades to unlabelled paragraph splits. The
first working build produced 286 plausible-looking chunks that were almost all
wrong; after normalizing, `security-faq.md` went from 29 blind splits to 46 real
question sections.
`normalizeNewlines()` runs before any parsing. Do not remove it, and do not
"fix" a future parsing bug here by adding `\r?` to one pattern.
## Token windows: why a chunk can hold several vectors
Chunking at heading boundaries keeps sections coherent, but it does not bound the
result by *tokens* — and it cannot. Measured on this corpus the ratio ranges from
**2.43 to 5.81 characters per token**, a factor of 2.4, so no character budget maps
reliably onto the model's 256-token window. A budget safe for the worst case
(~620 characters) would shred prose and tables alike.
Left alone, 87 of 324 chunks exceeded the window and **13% of all tokens never
reached the ranking** — silently, since the tokenizer truncates without complaint.
The effect was concrete: in `built-in-rules.md :: Apps` (734 tokens), the strings
`ANALYZE-APP-013`, `ANALYZE-OFFICE-001`, `ANALYZE-CORR-003` and `Click-to-Run` all
sit past the cutoff, so *"proxy configuration blocking app downloads"* did not
retrieve that section at all.
So the *chunk* stays whole and the *embedding* is split instead:
`VectorSearchProvider.index()` cuts long text into 250-token windows with 40 tokens
of overlap and embeds each. A document therefore carries `embeddings: number[][]`,
and `search()` scores it by its **best** window.
- **Max, not mean.** Averaging window vectors would blur a long section's several
topics into one mediocre direction — the same dilution that makes large context
windows worse than chunking. Max keeps a document findable by any one thing it
discusses.
- **The tokenizer is a build-time dependency only.** `index()` runs during the
Docker build; production boots from precomputed vectors and never loads it. It is
also only the vocabulary, not the ONNX weights (~93 ms).
- Cost: 324 chunks → 424 vectors (+31%), index 2.63 → 3.12 MB, and 424 instead of
324 dot products per query — still microseconds.
- The rules corpus benefits too, unplanned: 128 rules now carry 172 vectors.
Measured effect: token coverage 87% → **100%**, *"Office Click-to-Run install never
finished"* 0.318 → **0.442**, *"proxy configuration blocking app downloads"* absent
→ **0.559 at rank 1**. The ten reference queries kept byte-identical scores — their
chunks are short, hence one window, hence the same vector as before.
## Keyword fallback
Semantic ranking alone leaves a gap: proper nouns embed poorly. `RealmJoin` occurs
in 3 chunks yet scores under the threshold.
Fuzzy matching is the wrong remedy, and the corpus says so. At a threshold low
enough to surface `RealmJoin` (0.28), Fuse also returns 5 hits for `SCEPman` — a
word this corpus contains **zero** times. Literal containment has neither problem.
So when the semantic pass returns fewer than `topK`, `search_docs` tops the list up
via `lexicalMatch`, the substring scan every provider already exposes:
- Query terms are filtered to distinctive ones (≥4 characters, non-stopword, max 6).
- A hit needs **corroboration**: at least two matching terms, unless the query has
only one. Without this rule, "how do I bake sourdough bread" matched a page about
LLM providers, because "bake" is a substring of "baked into the image".
- Hits are **appended, never merged**: they carry no cosine similarity, so semantic
results keep the head of the list and keyword hits fill the tail, flagged
`matchType: "keyword"` with `keywordFallback` in the response.
### Language
The corpus and `all-MiniLM-L6-v2` are both English-only, and the failure is total
rather than gradual: "wo werden meine Daten gespeichert" returns **zero** results,
not weak ones, because the query vector lands nowhere near any English chunk.
The fix is a sentence in the tool description telling the caller to query in English
and answer in the user's language — the caller is a language model, so translating
the question before the call costs nothing. A multilingual embedding model would
handle it natively but is substantially larger and would need to be measured against
`scripts/eval-docs-search.ts` first, since it changes English retrieval too.
`search_knowledge` carries the same note for the same reason.
### When it fires
Two triggers, and the second one exists because the first was not enough:
1. **Fewer than `topK` semantic hits** — the obvious case.
2. **A weak semantic head** (best score below `WEAK_SEMANTIC_SCORE`, 0.35). Topping
up only a short list misses the more common failure: a pasted identifier like
`WOW6432Node` returns three chunks at ~0.29 that do not contain the word, while
the single chunk that does never gets considered. When nothing semantic is
convincing, exact matches take the front — keeping at least one semantic result,
so a weak-but-correct hit is never pushed out entirely.
The threshold was chosen by measurement, not intuition. `scripts/eval-docs-search.ts`
scores strategies over 2465 queries in three classes — every chunk heading
(known-item, a regression detector), rare identifiers (the class this fallback
serves), and hand-written questions (the reality anchor, not derived from the
corpus):
| strategy | known-item | literal | handwritten MRR | fixed | broken |
| --- | --- | --- | --- | --- | --- |
| append-only (previous) | 71.9% | 66.2% | 1.000 | — | — |
| weak < 0.30 | 71.9% | 72.6% | 1.000 | 142 | 0 |
| **weak < 0.35** | **73.7%** | **81.1%** | **1.000** | **335** | **0** |
| weak < 0.40 | 76.3% | 88.2% | 0.944 | 502 | 3 |
| weak < 0.45 | 77.2% | 93.0% | 0.889 | 613 | 5 |
0.35 is strictly dominant: better in every class, worse in none. Higher thresholds
keep repairing more identifier queries but start demoting hand-written questions —
the class that most resembles real use — so the gain is not free past that point.
The measurement also corrected the size of the problem: identifier queries were
succeeding only 66.2% of the time, so `WOW6432Node` was not an outlier but one of
roughly 750 such failures.
## Build-time embedding
Like the rules corpus, docs are embedded once at Docker build time by
`precompute-embeddings.js <rulesDir> <outFile> [docsDir]`, into a third
`PrecomputedSection` (`docs`) of `search-index.json`. The container never embeds
a corpus at boot — only the incoming query.
The `docs` section is **optional** in the file format, which is what makes a
local checkout without the docs repo work: `validatePrecomputedIndex` skips it
when the booting server loaded zero doc chunks. The asymmetry is intentional —
if the server *has* a docs corpus and the file does *not*, that is staleness and
is rejected like any other mismatch.
Embeddings are rounded to 6 decimals on serialize. A float64 component
serializes to ~20 characters of JSON, so 384 dims cost ~7.8 KB per vector;
rounding roughly halves the index file, which is `JSON.parse`d on every
scale-to-zero cold start. The induced L2 norm error (~1e-5 over 384 dims) sits
two orders of magnitude inside `UNIT_NORM_EPSILON`.
## Registration
`search_docs` is registered for **every role**, unlike the platform tools: this
is public documentation carrying no tenant data, and a tenant user asking "how do
I deploy the agent" has as much business here as a Global Admin. It is skipped
entirely when the corpus is empty, so a doc-less build advertises no broken tool.
# Cold start
`minReplicas: 0` means every idle period ends in a cold start someone waits through.
Measured from outside: **24.5 s** to first byte after `ScaledToZero`, against **60 ms**
warm.
Where those seconds go could not be answered from outside the process. Log Analytics
stamps every console line of one ingestion batch with the same time, so the boot
sequence collapses to a single instant however it is queried. The server therefore
times itself — `[boot +Nms]` marks from `process.uptime()`, which includes Node's own
startup and module loading.
Measured in production on a real cold start (31.5 s to first byte that time):
```
+244ms node started (startup + module loading)
+431ms corpora read from disk (+187ms)
+491ms index read, validated, hydrated (+60ms <- for 3.12 MB)
+504ms listening
+677ms query embedder warm (background)
```
**The application accounts for half a second of it.** The other ~31 s — 98% — is
Container Apps activation: KEDA reacting to the request, scheduling a pod, mounting
the 236 MB image, starting the container. The container also boots *faster* than a
development laptop (504 ms against 1273 ms), so nothing here is CPU-starved.
Three conclusions, and two of them overturn assumptions this file previously stated:
- **Index size is irrelevant to startup.** 3.12 MB hydrates in 60 ms. Growing the
corpus is effectively free, and the embedding rounding earns its keep on file size
alone, not on boot time.
- **Raising the container to 0.5 vCPU was not justified by cold start.** The stated
reason was the embedding model load; prebaked, that is 173 ms and runs in the
background, where only the first *search* could ever wait on it. The extra vCPU may
still help concurrent requests, but the cold-start argument does not survive
measurement.
- **The only real lever is `minReplicas: 1`**, which removes activation entirely at
roughly 7× the monthly free consumption grant. Shrinking the image would shave the
mount, but that is a fraction of platform time, not the bulk of it.
# Cross-repo coupling
**Editing the documentation does not update a running MCP server.** The corpus is
baked into the image, so only re-running the *Deploy MCP Server* workflow picks
up doc changes.
That coupling is made checkable rather than memorable: the build passes the docs
checkout SHA as `--build-arg DOCS_COMMIT`, and `/health` reports
`docs: { commit, chunks, sections }`. Comparing that commit against
`git rev-parse HEAD` in `autopilotmonitor-docs` answers "is the deployed
documentation stale?" directly.
Automatic redeploy on a docs push was considered and rejected: it would need a
PAT secret in the docs repo and would turn every typo fix into a container
deployment.
# Examples
Retrieval measured against the real bundle (323 chunks, all-MiniLM-L6-v2):
```
"where is my data stored" → 0.566 troubleshooting/faq.md :: General › Where is my data stored?
"how do I deploy the agent with Intune" → 0.819 troubleshooting/faq.md :: Setup & Agent › How do I deploy the agent?
"which roles exist and what can they do" → 0.500 trust/security-faq.md :: Identity and Access › What roles exist?
"how do I set up Teams notifications" → 0.592 reference/settings.md :: Tenant › Notifications
"what firewall URLs do I need to allow" → 0.374 reference/network-endpoints.md :: (intro)
```
The 0.25 `minScore` default is tuned to that observed band — short keyword
queries on all-MiniLM score low, and a relevant-but-marginal hit lands around
0.34.
# Citations
* `/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts` — loader, frontmatter
parser, chunker
* `/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts` — `search_docs`
registration
* `/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts` — `docs`
section and its validation asymmetry
* `/src/McpServer/autopilot-monitor-mcp/Dockerfile` — `mcp-docs/` copy,
`DOCS_COMMIT`
* `/.github/workflows/deploy-mcp.yml` — docs checkout and SHA capture
* `/src/McpServer/autopilot-monitor-mcp/scripts/eval-docs-search.ts` — retrieval
evaluation harness; run it before changing anything about ranking
* [MCP OAuth Flow](../mcp-oauth-flow.md) — the other MCP concept document
+81
View File
@@ -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
+95
View File
@@ -0,0 +1,95 @@
---
type: Concept
title: Gather Rule Guardrails
description: >-
How gather-rule collectors are fenced in — the allowlists in rules/guardrails.json,
the hard blocks that survive unrestricted mode, and why every collector must call a
guard rather than trusting rule.Target.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
tags: [gather-rules, security, guardrails, agent]
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, 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 — 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
Allowlists come from `rules/guardrails.json`, embedded into the agent assembly as
`AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json` (see the `EmbeddedResource`
item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal's
`utils/guardrails.generated.ts` by `node rules/scripts/combine.js`.
| Guard | Source list | Match rule |
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
| `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 |
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
but the hard blocks must hold regardless of configuration:
* `BlockedUsersPrefix` (`C:\Users`) and `AdditionalHardBlockedPathPrefixes`
(`C:\Windows\System32\config`) — exception only for `%LOGGED_ON_USER_PROFILE%`
under `AppData\Local` / `AppData\Roaming`.
* `HardBlockedCommandPatterns` — download, user/group creation, boot config,
persistence, destructive operations; plus `MaxCommandLength`.
* `HardBlockedEventLogChannels` — `Security` (audit trail of user behaviour) and the
PowerShell channels (script-block logging routinely contains secrets in clear text).
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
`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
Every collector must call its guard and return `context.EmitSecurityWarning(...)` on
refusal — that emits the `security_warning` event that makes a block visible in the
timeline instead of failing silently.
```csharp
// FileCollector / JsonCollector / XmlCollector
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
return context.EmitSecurityWarning(rule, "file", filePath);
```
Two collectors were added to this contract on 2026-07-22 after shipping without it:
* **`LogParserCollector`** read any path in `rule.Target`, bypassing even the `C:\Users`
hard block. It cannot guard `rule.Target` directly — a wildcard filename makes
`Path.GetFullPath` throw — so it guards **the directory** when the filename contains
`*`/`?`, and re-guards **each resolved path** after wildcard expansion (a junction can
surface a file the directory check did not cover).
* **`EventLogCollector`** read any channel, including `Security`.
`ImeLogPathOverride` comes only from the local `--ime-log-path` CLI flag, never from
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`
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs` — `EmitSecurityWarning`
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleCollectorGuardTests.cs`
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — what the backend does *not* validate
* `rules/guardrails.json`, `rules/scripts/combine.js`
* Customer-facing counterpart: `autopilotmonitor-docs/rules/gather-rules.md` (§ Security guardrails)
+74 -4
View File
@@ -1,14 +1,14 @@
---
type: Concept
title: Gather Rule Phase Scoping & Emit-on-Change
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules.
title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
tags:
- agent
- backend
- gather-rules
- config
timestamp: 2026-07-20T00:00:00+02:00
timestamp: 2026-08-04T00:00:00+02:00
---
# Problem
@@ -35,6 +35,64 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk
numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent
defensively prefers `activePhases` if both arrive anyway.
# One-shot collection at phase boundaries
Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For
one-shot collection the two phase triggers are the bookends of a phase:
| Trigger | Fires |
|---|---|
| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). |
| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). |
Empty `triggerPhase` means every transition. Both keys live in the same
`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an
enter-rule and an exit-rule on the same phase never cannibalise each other's slot.
`phase_exit` semantics that are easy to get wrong:
* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and
the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore
still fires on its own exit, even though the phase being entered is outside that scope.
* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing
state at the failure boundary is the point.
* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase
signal is not a transition.
* An exit rule on `Complete` only fires if something transitions *away* from Complete. For
"at the end of enrollment", use `phase_change` on `Complete` or `on_event` with
`enrollment_complete`.
* 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**:
@@ -78,6 +136,16 @@ defensively prefers `activePhases` if both arrive anyway.
`always`. The scope UI is a three-way mode (always / during phases / from phase) backed by
a canonical `GATHER_PHASES` list — the same list replaced the free-text `triggerPhase`
input, whose placeholder used to suggest invalid tokens.
* **Controls are shown only where they change behavior** (`supportsPhaseScope` /
`supportsEmitMode` in `types.ts`). Offering all three "when" controls unconditionally
produced meaningless combinations — a scope on a `phase_change` rule naming a concrete
phase can only ever suppress the single firing, and `on_change` has no second result to
compare against. Hidden controls are nulled by `buildScopeFields` so state from an earlier
trigger choice cannot leak into the payload, and `validateScopeSelection` rejects a scope
mode that was selected but left unfilled (which previously serialised to null and silently
ran the rule everywhere while the form still read "From a phase onwards").
Startup is the deliberate exception: a scope there defers the one-shot rather than
suppressing it, so the scope control stays while emit mode is hidden.
* The custom-rule toggle PUT (`{enabled, isBuiltIn, isCommunity}`) previously full-replaced
the stored rule and wiped Title/Target/Trigger — `GatherRuleService.UpdateRuleAsync` now
merges toggle-style partials (empty Title/CollectorType/Target) into the existing row and
@@ -91,7 +159,9 @@ defensively prefers `activePhases` if both arrive anyway.
# 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)
+152
View File
@@ -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
+121
View File
@@ -0,0 +1,121 @@
---
type: Concept
title: URL Registry — One Place per Component, Guard-Enforced
description: Every well-known own or Microsoft host lives in exactly one registry file per component (Constants.cs, utils/config.ts, src/config.ts); guard tests fail the build on any literal outside them. Includes the agent-download host migration to the download alias.
resource: /src/Shared/AutopilotMonitor.Shared/Constants.cs
tags:
- architecture
- urls
- ci
- convention
timestamp: 2026-07-23T00:00:00+02:00
---
# URL Registry
Every well-known base URL — own hosts (portal, www, docs, download alias, MCP,
API, both blob accounts) and Microsoft hosts (Graph, Entra login) — is defined
in exactly one registry file per component. Code references the constant; the
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`, `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:
`lib/hostRouting.ts` computes `PUBLIC_HOST`/`PORTAL_HOST`/`APEX_HOST` from the
URL constants, and `middleware.ts` imports from it.
# Schema
The guards scan non-test source for the enforced host list and fail with
file:line on any occurrence outside the registry. Comment lines are ignored
(docs may cite URLs). Test files are excluded on purpose: a literal in a test
is an independent oracle — `ValidateBootstrapCodeResponseShapeTests` spells the
full agent download URL out so a registry change must be acknowledged in two
places, backend and portal allow-list, consciously.
Why enforced rather than agreed: the registry existed before (both
`Constants.AgentBlobBaseUrl` and the MCP `config.ts` header document earlier
drift), yet the EU cutover still missed two literal copies of the blob host —
`LatestVersionsService` and `ValidateBootstrapCodeFunction` kept reading the
legacy account, silently kept alive only by the fail-soft legacy mirror in
`build-agent.yml`. A convention that is not checked decays; these guards make
the next stray literal a red build instead of a production surprise.
# Agent-download host migration (2026-07-23)
The two legacy readers above plus the agent's `SelfUpdater` now use
`Constants.AgentDownloadBaseUrl` (`download.autopilotmonitor.com/agent`, Front
Door in front of the current blob origin — verified serving `version.json`,
`version-v2.json` and both ZIPs before the switch). The portal's
`bootstrapValidation.ts` allow-lists BOTH the alias and the legacy blob host
(`AGENT_DOWNLOAD_HOSTNAMES`) because bootstrap scripts already deployed in
customer Intune tenants still carry legacy URLs.
Deploy-order constraint: the web allow-list must be live BEFORE (or with) the
backend that serves alias URLs — an old portal build rejects the alias host and
the bootstrap flow dies client-side. Remove the legacy hostname from the
allow-list only after the legacy account is torn down.
**Front Door caching is a correctness hazard here, not a staleness nuisance.**
The first release after the migration exposed it: the alias kept serving the
previous `version.json` (`X-Cache: TCP_REMOTE_HIT`) while the origin was already
updated — and because manifest and ZIP are independent cache entries, a stale
manifest paired with a fresh ZIP fails the bootstrap's SHA-256 check. Two
defenses, both in place since 2026-07-23: route caching on `/agent/*` is
DISABLED, and every blob upload (CI `Send-Blob`, local `build.ps1`, the
`versions/*.json` manifests) stamps `Cache-Control: no-cache` — all these blobs
rotate in place, even the per-line ZIP. If caching is ever re-enabled, the
headers keep the mismatch class impossible.
Deliberately still on the legacy account: the `HealthCheckService` /
`MaintenanceService` legacy-keepalive probes and the fail-soft mirror in
`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
- `src/Web/autopilot-monitor-web/utils/config.ts` — web registry
- `src/McpServer/autopilot-monitor-mcp/src/config.ts` — MCP registry
- `src/Backend/AutopilotMonitor.Functions.Tests/HardcodedUrlGuardTests.cs`
- [Version Contract](versioning.md) — the release pipeline this migration protects
+108
View File
@@ -0,0 +1,108 @@
---
type: Concept
title: Version Contract — Agent, Backend, MCP
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
resource: /.github/scripts/Request-BuildNumber.ps1
tags:
- build
- ci
- release
- versioning
timestamp: 2026-07-22T00:00:00+02:00
---
# Version Contract
Three components ship from this repository — the agent, the Functions backend and the
MCP server. They share one version shape, one way of minting build numbers, and one
manifest format, so that a version string means the same thing whoever emits it.
```
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
commit = 7-char short SHA of this repo
buildUtc = ISO-8601 build moment
```
| Component | major.minor from | Counter blob | Reported by |
|---|---|---|---|
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
# Schema
## Build numbers — reserve before build, ETag-CAS
`.github/scripts/Request-BuildNumber.ps1` is the only implementation; the CI
workflows and the local `build.ps1` all call it.
1. Anonymous `GET` on the counter blob → value `N` and `ETag`. The blob holds the
**last used** number as plain text and is public-read; only the write needs the
container SAS.
2. `PUT N+1` with `If-Match: <etag>`. The quoted ETag form is required — PowerShell
strips the quotes on read, and an unquoted `If-Match` is rejected client-side.
3. `412 Precondition Failed` → a concurrent build reserved first → re-read and retry
(5 attempts, linear backoff).
4. Build with the reserved number.
Reserving *before* the build is what makes concurrent minting impossible. **A failed
build burns a number** — deliberate: uniqueness matters, density does not.
Why a counter and not the published manifest: manifests are written on publish, not on
build. The agent writes `version.json` only for stable releases, so deriving the next
number from it would hand two consecutive dev builds the same number and let the second
overwrite the first's artifact. A state-free scheme such as `git rev-list --count HEAD`
fails for the same reason — two builds of one commit collide.
**Seeding** (once per component): `PUT <last used number>` with `If-None-Match: *`, so
an existing counter can never be clobbered. Agent V2 was seeded with `1356` on
2026-07-17; `versions/backend.txt` with `999` and `versions/mcp.txt` with `299` on
2026-07-22.
## Local builds
No counter is reached, the build number stays `0`, and the commit is empty or
`unknown`. A `1.5.0` with no commit hash on `/api/health` is therefore a developer
machine, not a release — the fallback is the signal, not a defect. The agent is the
exception: `build.ps1` produces uploadable artifacts and reserves a real number unless
`-SkipUpload` is passed.
## Manifests
`versions/backend.json` and `versions/mcp.json` (public-read) record what is deployed:
```json
{ "component": "backend", "version": "1.5.1000", "commit": "abc1234",
"buildUtc": "…", "deployedUtc": "…", "runId": "…" }
```
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
documentation" are separate questions.
Each is written **last and only after the deploy workflow has polled the component's
health endpoint until it reports that exact version and commit**. A green deploy step
alone means the package was accepted, not that it is being served. Consequently a
manifest that disagrees with the live endpoint means the running instance drifted after
the fact (rollback, stale revision) rather than a half-finished pipeline.
The agent keeps its own manifest at `agent/version.json` — the bootstrap derives that
URL from the download URL and verifies the ZIP against its `sha256`, so it cannot move.
It carries the same `commit` and `buildUtc` fields; `sha256` and `bootstrapVersion`
remain, being the customer-facing integrity contract.
# Consequences
- Bumping `major.minor` is a human decision, made in the component's own file
(`VersionPrefix`, or `version` in `package.json`). Nothing bumps it automatically.
- A new component joins by seeding a counter blob and calling the same script; no new
mechanism.
- The counter blobs and manifests live in the `versions` container on
`autopilotmonitoreu` (writes via `AZURE_BLOB_VERSIONS_SAS_TOKEN`). The agent's
counter stays in the `agent` container next to the artifacts it numbers.
# Citations
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
+126
View File
@@ -0,0 +1,126 @@
---
type: Concept
title: Portal Navigation Prefetch & the SWA Runtime Bottleneck
description: Why every navigation Link in the portal sets prefetch={false} — the App Router's viewport prefetch fires one RSC request per visible link, and the Static Web Apps SSR runtime queues them server-side into a multi-minute latency tail. Also records what the follow-up measurement showed about the Standard SKU and the keep-alive side effect.
resource: src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
tags:
- web
- performance
- nextjs
- static-web-apps
- observability
timestamp: 2026-07-27T18:00:00+02:00
---
# Context
The portal is a Next.js 15 App Router application hosted on Azure Static Web Apps. Every page under
the authenticated shell is a client component behind `ProtectedRoute` that fetches its own data from
the Function App (`autopilotmonitor-api-eu`) after mount. The RSC payload a route returns on
navigation therefore carries no page data — only the shell.
`GlobalSidebar` and `Navbar` render the full navigation on every page. With the App Router's default
prefetch behaviour, each visible `<Link>` triggers a background `GET /<route>?_rsc=<hash>` as soon as
it enters the viewport.
# Mechanism
Two properties combine into a portal-wide freeze:
1. **Fan-out.** The navigation is fully rendered on each page load, so a single navigation issues one
RSC request per visible link — measured at ~10,400 requests over 14 days.
2. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
(`x-nextjs-cache: HIT`, `s-maxage=31536000`), so the median is fast. On a cache miss or cold start
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
The navigation the user wants is then stuck behind that queue and the tab shows a loading spinner or a
blank page until it drains. Nothing fails — the requests eventually return `200`, which is why the
failure leaves no error trace anywhere.
> **Correction (2026-07-28).** The original version of this document named a third property: that
> `portal.autopilotmonitor.com` negotiates HTTP/1.1 and the prefetches exhaust the six-connection
> browser cap. **That is wrong.** It came from `curl -w '%{http_version}'` on a Windows build of curl
> whose Schannel backend has no HTTP/2 support, so it can only ever report `1.1`. ALPN says otherwise:
> `openssl s_client -alpn h2,http/1.1` negotiates **h2** on the portal host, confirmed by a .NET
> `HttpClient` request returning `HTTP/2.0`. There is no six-connection cap and connection starvation
> was never the mechanism — the queue is server-side. Verify HTTP versions with `openssl s_client
> -alpn` or `HttpClient`, never with this curl build.
# Evidence
Incident of 2026-07-27, 17:25–17:29 CEST. Browser-side dependencies during the freeze:
| Request | Duration | Status |
| --- | --- | --- |
| `/admin/presence?_rsc=…` | 151,461 ms | 200 |
| `/gather-rules?_rsc=…` | 52,283 ms | 200 |
| `/progress?_rsc=…` | 37,878 ms | 200 |
| `/analyze-rules?_rsc=…` | 37,559 ms | 200 |
| `/audit?_rsc=…` | 37,539 ms | 200 |
| Backend API, same window | 1,600–2,200 ms | 200 |
The three ~37.5 s responses complete within 340 ms of each other — a queue draining, not independent
slow requests. The corresponding `browserTimings` row for `/geographic-performance` reports
`networkDuration` = 16 ms against `processingDuration` = 225,095 ms: the network delivered the
document immediately and the browser then waited nearly four minutes for the `load` event.
Fourteen-day distribution of RSC prefetches: n = 10,428, p50 = 91 ms, p95 = 896 ms, max = 234,230 ms,
38 over 10 s, 21 over 30 s.
# Outcome, measured 2026-07-28
Two changes landed within half an hour of each other on 2026-07-27: the SWA plan went Free → Standard
at 16:02:44 UTC, and `prefetch={false}` went live at 16:34:13 UTC. Twenty hours of production
telemetry separate their effects.
**The Standard plan produced no measurable latency gain.** Latency is governed almost entirely by the
gap to the previous request — the SSR runtime cools down — so raw percentiles only measure the traffic
mix. Stratified by that gap, the two eras are indistinguishable (p50, Free → Standard): `<10s`
85 → 95 ms · `10-60s` 100 → 117 ms · `1-5min` 295 → 310 ms · `5-30min` 429 → 441 ms. Tail rates match
too: Free ran 1.64 % over 2 s and 0.374 % over 10 s (n = 10,163); Standard produced 6 and 1 events at
n = 258 against 4.2 and 1.0 expected. `browserTimings.totalDuration` by Mann-Whitney: n = 436 vs 48,
z = −0.24, **p = 0.81**.
**The prefetch fix is what removed the freezes** — worst `browserTimings.totalDuration` fell from
225,277 ms to 4,856 ms, and the 30-second events stopped.
**It also removed an accidental keep-alive.** The prefetch storm kept the SSR runtime warm: 85 % of
requests used to arrive less than 10 s apart, now 46 % do. On an identical never-cacheable route
(`/sessions/{id}`) the median rose from 87 ms to 211 ms and p90 from 465 ms to 1,480 ms. After 517
minutes idle, one request took 11,441 ms and was abandoned (`resultCode 0`). The trade — minute-long
freezes gone, ~150 ms added to a cold navigation — is worth it, but it is a trade.
Do not benchmark this with a synthetic burst. Firing requests in quick succession warms the runtime
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
*absence* of requests, never as an error. Browser telemetry lives in a **separate** Application
Insights resource addressed by `AUTOPILOT_MONITOR_WEB_APPINSIGHTS_ID`, which the MCP
`query_backend_logs` tool does not reach. Query it through
`.claude/commands/scripts/query-appinsights.sh` with that ID exported, and read `dependencies`
(client-side fetches, including RSC prefetches) and `browserTimings` (the network/processing split of
a document load).
# Contract
Navigation `<Link>` elements in `GlobalSidebar` and `Navbar` set `prefetch={false}`. The prefetched
RSC payload buys nothing for these routes, so the only remaining cost of a click is the route's JS
chunk, which the browser caches after first use.
Do not remove the prop to "speed up navigation" without first re-measuring the tail in the web
Application Insights resource — the prefetch is cheap only while the runtime is warm.
# Citations
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
+268
View File
@@ -0,0 +1,268 @@
// Autopilot-Monitor MCP Server — Azure Container App Infrastructure
//
// Reconciled against the live deployment on 2026-07-21 (rg-autopilotmonitor-prd-gwc,
// Germany West Central). The template had drifted behind two changes made directly
// in Azure: the EU cutover (registry/API renamed) and the ACR hardening (admin user
// disabled, image pull moved to the app's system-assigned identity).
//
// !! BEFORE RE-DEPLOYING THIS TEMPLATE, READ THIS !!
// The live app carries an ingress custom-domain binding for mcp.autopilotmonitor.com
// (SNI-enabled, with a managed certificate). Custom domains are NOT modelled below,
// because the certificate is bound out-of-band. A full template deployment would
// therefore REMOVE that binding and take the public MCP endpoint offline.
// For routine changes (CPU/memory, env vars, image) prefer the surgical CLI form:
// az containerapp update -n autopilotmonitor-mcp -g rg-autopilotmonitor-prd-gwc --cpu 0.5 --memory 1.0Gi
// Treat this file as the documented desired state and as a rebuild-from-scratch
// recipe — not as a routinely-applied deployment.
//
// On a FROM-SCRATCH deploy the AcrPull assignment below is created only after the
// Container App exists (it needs the app's principalId), so the very first revision
// can fail to pull its image. Re-run the deployment, or restart the revision, once
// the role assignment has propagated. This does not affect updates to a live app.
//
// Deploy (two-stage; required to set the OAuth public URL safely):
// 1. First deploy without mcpPublicUrl:
// az deployment group create \
// --resource-group <rg-name> \
// --template-file infra/mcp-server.bicep \
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
// entraClientSecret=<secret-value>
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
//
// 2. Re-deploy with that URL pinned, so the OAuth proxy never trusts caller-
// supplied X-Forwarded-Host / Host headers when building issuer / WWW-
// Authenticate / redirect_uri metadata:
// --parameters mcpPublicUrl=<containerAppUrl from step 1>
//
// When mcpPublicUrl is set, the MCP server uses it verbatim and ignores any
// forwarded-headers from the request. Without it, the server logs a startup
// warning and falls back to header-derived URLs (acceptable for local dev,
// not for production).
@description('Azure region for all resources')
param location string = resourceGroup().location
@description('Name of the Container App')
param containerAppName string = 'autopilotmonitor-mcp'
@description('Name of the Container App Environment')
param environmentName string = 'autopilotmonitor-env'
@description('Name of the Log Analytics Workspace (dedicated for Container Apps)')
param logAnalyticsName string = 'autopilotmonitor-container-logs'
@description('Name of the Azure Container Registry')
param acrName string = 'autopilotmonitoreuacr'
@description('Backend API URL')
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. 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')
param entraClientSecret string = ''
@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).')
param mcpAllowedRedirectHosts string = ''
@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).')
param mcpPublicUrl string = ''
@secure()
// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode
// >=). `az bicep build --stdout` writes through the Windows console codec and
// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine.
@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). Volume is one small JSON line per tool call - negligible at current usage.')
param toolLoggingEnabled bool = true
// --- Azure Container Registry ---
// Admin user is DISABLED: a registry-wide username/password pair is a static,
// non-rotating credential that would also have to live in a Container App secret.
// The app pulls with its system-assigned identity + AcrPull instead (see below).
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: acrName
location: location
sku: {
name: 'Basic'
}
properties: {
adminUserEnabled: false
}
}
// AcrPull for the Container App's system-assigned identity — this is what replaces
// the admin credentials. Without it the app cannot pull its own image.
//
// NOTE: this assignment exists on the live registry but could not be enumerated
// during reconciliation (the CLI session lacks Microsoft.Authorization read), so it
// is reproduced here from the documented pattern rather than read back from Azure.
// Verify with:
// az role assignment list --scope <acr-id> --assignee <containerApp principalId>
var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: acr
name: guid(acr.id, containerAppName, acrPullRoleId)
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId)
principalId: containerApp.identity.principalId
principalType: 'ServicePrincipal'
}
}
// --- Log Analytics Workspace (required by Container App Environment) ---
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: logAnalyticsName
location: location
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: 30
}
}
// --- Container App Environment ---
resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: environmentName
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: logAnalytics.properties.customerId
sharedKey: logAnalytics.listKeys().primarySharedKey
}
}
}
}
// --- Container App ---
resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
name: containerAppName
location: location
// System-assigned identity: the credential the app uses to pull from the ACR
// (see the AcrPull assignment above). Replaces the former admin-user secret.
identity: {
type: 'SystemAssigned'
}
properties: {
managedEnvironmentId: environment.id
configuration: {
ingress: {
external: true
targetPort: 8080
transport: 'auto'
allowInsecure: false
}
// 'system' = pull with the app's system-assigned identity. No username, no
// password secret — nothing to rotate and nothing to leak from the app config.
registries: [
{
server: acr.properties.loginServer
identity: 'system'
}
]
secrets: [
{
name: 'entra-client-secret'
value: entraClientSecret
}
{
name: 'oauth-state-signing-key'
value: oAuthStateSigningKey
}
]
activeRevisionsMode: 'Single'
}
template: {
containers: [
{
name: 'mcp-server'
image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}'
// 0.5 vCPU, but NOT for the reason originally given. It was raised from
// 0.25 to shorten the scale-to-zero cold start, on the assumption that
// loading the ONNX embedding model dominated it. Measurement disproved
// that: the app boots in 504 ms in production (see the [boot] marks in
// the container log and docs/mcp/docs-corpus.md), of which the prebaked
// embedder is 173 ms in the background. Of a 31 s cold start, ~98% is
// Container Apps activation — pod scheduling, image mount, container
// start — which no CPU setting touches.
//
// It stays at 0.5 because it plausibly helps CONCURRENT requests
// (maxReplicas is 1, so one replica serves up to 10 in-flight calls) and
// because at scale-to-zero the container bills only while active, making
// the difference negligible. If cold start ever needs to actually go
// away, the only lever is minReplicas: 1. Container Apps accepts fixed
// CPU/memory pairs, so memory moves with the CPU.
resources: {
cpu: json('0.5')
memory: '1.0Gi'
}
env: [
{ name: 'AUTOPILOT_API_URL', value: apiUrl }
{ name: 'PORT', value: '8080' }
{ name: 'AUTOPILOT_ENTRA_CLIENT_ID', value: entraClientId }
{ name: 'AUTOPILOT_ENTRA_CLIENT_SECRET', secretRef: 'entra-client-secret' }
{ name: 'MCP_ALLOWED_REDIRECT_HOSTS', value: mcpAllowedRedirectHosts }
{ name: 'MCP_PUBLIC_URL', value: mcpPublicUrl }
{ name: 'OAuthStateSigningKey', secretRef: 'oauth-state-signing-key' }
{ name: 'MCP_TOOL_LOGGING', value: toolLoggingEnabled ? 'true' : 'false' }
]
probes: [
{
type: 'Liveness'
httpGet: {
path: '/health'
port: 8080
}
periodSeconds: 30
initialDelaySeconds: 10
}
{
type: 'Readiness'
httpGet: {
path: '/health'
port: 8080
}
periodSeconds: 10
initialDelaySeconds: 5
}
]
}
]
scale: {
minReplicas: 0
maxReplicas: 1
rules: [
{
name: 'http-scaler'
http: {
metadata: {
concurrentRequests: '10'
}
}
}
]
}
}
}
}
// --- Outputs ---
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp'
output acrLoginServer string = acr.properties.loginServer
+85
View File
@@ -0,0 +1,85 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ESP-005",
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"conditions": [
{
"signal": "reboot_forcing_policy_uri",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "firstRebootUri",
"operator": "exists",
"required": true
},
{
"signal": "reboot_observed",
"source": "event_count",
"eventType": "system_reboot_detected",
"operator": "count_gte",
"value": "1",
"required": true
},
{
"signal": "multiple_reboot_uris",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "uriCount",
"operator": "gte",
"value": "2",
"required": false
},
{
"signal": "omadm_reboot_flag_set",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "omadmRebootRequiredFlag",
"operator": "equals",
"value": "true",
"required": false
}
],
"confidenceFactors": [
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
],
"confidenceThreshold": 70,
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
"remediation": [
{
"title": "Reassign the reboot-forcing policies to user groups",
"steps": [
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
]
},
{
"title": "Or accept the reboot and set expectations",
"steps": [
"If the policy must be device-assigned (e.g. a security baseline), keep it and document the expected mid-ESP restart",
"Communicate to users that a restart and second sign-in during provisioning is expected for these deployments"
]
}
],
"relatedDocs": [
{
"title": "Patch My PC: Avoid the second sign-in screen after Autopilot",
"url": "https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/"
},
{
"title": "Microsoft Learn: Enrollment Status Page tracking",
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
}
],
"markSessionAsFailedDefault": false,
"tags": ["esp", "reboot", "coalescing", "second-sign-in", "policy-attribution", "user-experience"]
}
+90
View File
@@ -0,0 +1,90 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ESP-006",
"title": "ESP Failure Recovered After User Retry",
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.0.0",
"enabled": true,
"baseConfidence": 85,
"conditions": [
{
"signal": "advisory",
"source": "event_type",
"eventType": "esp_failure_advisory",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "recovered",
"source": "event_type",
"eventType": "esp_failure_recovered",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "retry",
"source": "event_type",
"eventType": "esp_failure_retry_detected",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "category",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "category",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "failedSubcategory",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "failedSubcategory",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "likelyCulpritApps",
"source": "event_type",
"eventType": "esp_failure_advisory",
"dataField": "likelyCulpritApps",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "minutesSinceFailure",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "minutesSinceFailure",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{ "signal": "retry", "condition": "exists", "weight": 10 },
{ "signal": "likelyCulpritApps", "condition": "exists", "weight": 5 }
],
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
"remediation": [
{
"title": "Make the first install attempt stick",
"steps": [
"Check the likely culprit app(s) (`{{likelyCulpritApps}}`) in Intune: an app that fails on the first ESP pass but succeeds on retry usually has a dependency/ordering or detection-timing problem.",
"If the app shows 'never started installing' in esp_failure_settle_started, verify its assignment and applicability rules — ESP counted it as required, but IME never processed it.",
"If the pattern repeats across the tenant, consider removing the app from the ESP blocking list until the first-attempt install is reliable — users are seeing an avoidable failure screen.",
"No device-side action is needed for this session: the failure recovered and the enrollment continued."
]
}
],
"tags": ["esp", "recovery", "try-again", "retry", "continue-anyway", "enrollment"]
}
+33 -5
View File
@@ -2,10 +2,10 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-001",
"title": "Secure Boot UEFI CA 2023 certificate not deployed",
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed.",
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "2.0.0",
"version": "3.0.0",
"enabled": true,
"baseConfidence": 70,
"preconditions": [
@@ -16,6 +16,14 @@
"operator": "equals",
"value": "false",
"description": "Skip on virtual machines — Secure Boot DB is host-controlled (Hyper-V/VMware/etc), the inside-the-VM UEFICA2023Status registry value is not the right indicator and not actionable for the VM admin."
},
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiCa2023FirmwareConfirmed",
"operator": "not_exists",
"value": "",
"description": "Skip when the firmware db itself confirms the Windows UEFI CA 2023 certificate — the marker is only emitted on positive firmware confirmation, so old agents (field absent) and unconfirmed devices keep evaluating the rule. The servicing registry status lags behind actual firmware state (OEM factory image, WinCS, manual deployment, or WU simply not run yet)."
}
],
"conditions": [
@@ -54,13 +62,33 @@
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "db_ca2023_firmware_missing",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiDbHasWindowsUefiCa2023",
"operator": "equals",
"value": "false",
"required": false
},
{
"signal": "kek_2023_missing",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiKekHasMicrosoftKek2kCa2023",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{ "signal": "ca2023_notfound", "condition": "exists", "weight": 20 },
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 }
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 },
{ "signal": "db_ca2023_firmware_missing", "condition": "exists", "weight": 20 },
{ "signal": "kek_2023_missing", "condition": "exists", "weight": 10 }
],
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nCurrent UEFI CA 2023 status: `{{uefiCA2023Status}}`",
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
"remediation": [
{
"title": "Deploy UEFI CA 2023 certificate now",
@@ -69,7 +97,7 @@
"Use Intune policy to enable Secure Boot DB update (Windows UEFI CA 2023)",
"Check with the device OEM whether a firmware update is required for the DB update to apply on this model",
"Alternatively, use the WinCS CLI tool from Microsoft for manual deployment",
"Verify deployment: check registry HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated'",
"Verify deployment against the firmware directly: [Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023') — this is authoritative; the registry value HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated' only reflects the Windows Update rollout",
"After DB update: ensure the boot manager is updated to a 2023-signed version via monthly cumulative update"
]
}
+78
View File
@@ -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"]
}
+330 -4
View File
@@ -1785,6 +1785,209 @@
"enrollment"
]
},
{
"ruleId": "ANALYZE-ESP-005",
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"conditions": [
{
"signal": "reboot_forcing_policy_uri",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "firstRebootUri",
"operator": "exists",
"required": true
},
{
"signal": "reboot_observed",
"source": "event_count",
"eventType": "system_reboot_detected",
"operator": "count_gte",
"value": "1",
"required": true
},
{
"signal": "multiple_reboot_uris",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "uriCount",
"operator": "gte",
"value": "2",
"required": false
},
{
"signal": "omadm_reboot_flag_set",
"source": "event_data",
"eventType": "mdm_policy_reboot_required",
"dataField": "omadmRebootRequiredFlag",
"operator": "equals",
"value": "true",
"required": false
}
],
"confidenceFactors": [
{
"signal": "multiple_reboot_uris",
"condition": "exists",
"weight": 10
},
{
"signal": "omadm_reboot_flag_set",
"condition": "exists",
"weight": 15
}
],
"confidenceThreshold": 70,
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
"remediation": [
{
"title": "Reassign the reboot-forcing policies to user groups",
"steps": [
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
]
},
{
"title": "Or accept the reboot and set expectations",
"steps": [
"If the policy must be device-assigned (e.g. a security baseline), keep it and document the expected mid-ESP restart",
"Communicate to users that a restart and second sign-in during provisioning is expected for these deployments"
]
}
],
"relatedDocs": [
{
"title": "Patch My PC: Avoid the second sign-in screen after Autopilot",
"url": "https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/"
},
{
"title": "Microsoft Learn: Enrollment Status Page tracking",
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
}
],
"markSessionAsFailedDefault": false,
"tags": [
"esp",
"reboot",
"coalescing",
"second-sign-in",
"policy-attribution",
"user-experience"
]
},
{
"ruleId": "ANALYZE-ESP-006",
"title": "ESP Failure Recovered After User Retry",
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.0.0",
"enabled": true,
"baseConfidence": 85,
"conditions": [
{
"signal": "advisory",
"source": "event_type",
"eventType": "esp_failure_advisory",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "recovered",
"source": "event_type",
"eventType": "esp_failure_recovered",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "retry",
"source": "event_type",
"eventType": "esp_failure_retry_detected",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "category",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "category",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "failedSubcategory",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "failedSubcategory",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "likelyCulpritApps",
"source": "event_type",
"eventType": "esp_failure_advisory",
"dataField": "likelyCulpritApps",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "minutesSinceFailure",
"source": "event_type",
"eventType": "esp_failure_recovered",
"dataField": "minutesSinceFailure",
"operator": "exists",
"value": "",
"required": false
}
],
"confidenceFactors": [
{
"signal": "retry",
"condition": "exists",
"weight": 10
},
{
"signal": "likelyCulpritApps",
"condition": "exists",
"weight": 5
}
],
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
"remediation": [
{
"title": "Make the first install attempt stick",
"steps": [
"Check the likely culprit app(s) (`{{likelyCulpritApps}}`) in Intune: an app that fails on the first ESP pass but succeeds on retry usually has a dependency/ordering or detection-timing problem.",
"If the app shows 'never started installing' in esp_failure_settle_started, verify its assignment and applicability rules — ESP counted it as required, but IME never processed it.",
"If the pattern repeats across the tenant, consider removing the app from the ESP blocking list until the first-attempt install is reliable — users are seeing an avoidable failure screen.",
"No device-side action is needed for this session: the failure recovered and the enrollment continued."
]
}
],
"tags": [
"esp",
"recovery",
"try-again",
"retry",
"continue-anyway",
"enrollment"
]
},
{
"ruleId": "ANALYZE-ID-001",
"title": "Expected Machine Certificate Not Found",
@@ -2079,10 +2282,10 @@
{
"ruleId": "ANALYZE-SEC-001",
"title": "Secure Boot UEFI CA 2023 certificate not deployed",
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed.",
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "2.0.0",
"version": "3.0.0",
"enabled": true,
"baseConfidence": 70,
"preconditions": [
@@ -2093,6 +2296,14 @@
"operator": "equals",
"value": "false",
"description": "Skip on virtual machines — Secure Boot DB is host-controlled (Hyper-V/VMware/etc), the inside-the-VM UEFICA2023Status registry value is not the right indicator and not actionable for the VM admin."
},
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiCa2023FirmwareConfirmed",
"operator": "not_exists",
"value": "",
"description": "Skip when the firmware db itself confirms the Windows UEFI CA 2023 certificate — the marker is only emitted on positive firmware confirmation, so old agents (field absent) and unconfirmed devices keep evaluating the rule. The servicing registry status lags behind actual firmware state (OEM factory image, WinCS, manual deployment, or WU simply not run yet)."
}
],
"conditions": [
@@ -2131,6 +2342,24 @@
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "db_ca2023_firmware_missing",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiDbHasWindowsUefiCa2023",
"operator": "equals",
"value": "false",
"required": false
},
{
"signal": "kek_2023_missing",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiKekHasMicrosoftKek2kCa2023",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
@@ -2143,9 +2372,19 @@
"signal": "ca2023_error",
"condition": "exists",
"weight": 10
},
{
"signal": "db_ca2023_firmware_missing",
"condition": "exists",
"weight": 20
},
{
"signal": "kek_2023_missing",
"condition": "exists",
"weight": 10
}
],
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nCurrent UEFI CA 2023 status: `{{uefiCA2023Status}}`",
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
"remediation": [
{
"title": "Deploy UEFI CA 2023 certificate now",
@@ -2154,7 +2393,7 @@
"Use Intune policy to enable Secure Boot DB update (Windows UEFI CA 2023)",
"Check with the device OEM whether a firmware update is required for the DB update to apply on this model",
"Alternatively, use the WinCS CLI tool from Microsoft for manual deployment",
"Verify deployment: check registry HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated'",
"Verify deployment against the firmware directly: [Text.Encoding]::ASCII.GetString((Get-SecureBootUEFI -Name db).Bytes).Contains('Windows UEFI CA 2023') — this is authoritative; the registry value HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\Servicing\\UEFICA2023Status = 'Updated' only reflects the Windows Update rollout",
"After DB update: ensure the boot manager is updated to a 2023-signed version via monthly cumulative update"
]
}
@@ -2448,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"
]
}
]
}
+98 -3
View File
@@ -43,7 +43,8 @@
"prefixes": [
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon"
"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
"SOFTWARE\\Microsoft\\Windows\\Autopilot"
]
},
{
@@ -109,6 +110,79 @@
"SELECT * FROM Win32_LogicalDisk",
"SELECT * FROM SoftwareLicensingProduct"
],
"eventLogChannels": [
{
"category": "Core Windows logs",
"channels": [
"Application",
"System",
"Setup",
"Microsoft-Windows-Kernel-Boot",
"Microsoft-Windows-Diagnostics-Performance"
]
},
{
"category": "MDM / Enrollment",
"channels": [
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider",
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider",
"Microsoft-Windows-Provisioning-Diagnostics-Provider",
"Microsoft-Windows-AAD",
"Microsoft-Windows-User Device Registration"
]
},
{
"category": "ESP / Shell / Apps",
"channels": [
"Microsoft-Windows-Shell-Core",
"Microsoft-Windows-AppXDeployment",
"Microsoft-Windows-AppXDeploymentServer",
"Microsoft-Windows-AppReadiness",
"Microsoft-Windows-Store"
]
},
{
"category": "Security posture / Crypto",
"channels": [
"Microsoft-Windows-BitLocker-API",
"Microsoft-Windows-BitLocker-DrivePreparationTool",
"Microsoft-Windows-TPM-WMI",
"Microsoft-Windows-CertificateServicesClient-Lifecycle-System"
]
},
{
"category": "Update / Servicing",
"channels": [
"Microsoft-Windows-WindowsUpdateClient"
]
},
{
"category": "Logon / Session",
"channels": [
"Microsoft-Windows-Winlogon",
"Microsoft-Windows-User Profile Service",
"Microsoft-Windows-GroupPolicy",
"Microsoft-Windows-TaskScheduler"
]
},
{
"category": "Network",
"channels": [
"Microsoft-Windows-NetworkProfile",
"Microsoft-Windows-Dhcp-Client",
"Microsoft-Windows-DNS-Client",
"Microsoft-Windows-NCSI",
"Microsoft-Windows-WLAN-AutoConfig",
"Microsoft-Windows-Time-Service"
]
}
],
"blockedEventLogChannels": [
"Security",
"Microsoft-Windows-PowerShell",
"Windows PowerShell",
"Microsoft-Windows-Sysmon"
],
"allowedCommands": [
{
"category": "TPM and Security",
@@ -176,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
}
+4 -4
View File
@@ -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",
@@ -80,8 +80,8 @@
},
"trigger": {
"type": "string",
"description": "When to collect data.",
"enum": ["startup", "phase_change", "interval", "on_event"]
"description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.",
"enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"]
},
"intervalSeconds": {
"type": "integer",
@@ -90,7 +90,7 @@
},
"triggerPhase": {
"type": "string",
"description": "Phase to trigger on (only used when trigger = \"phase_change\")."
"description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition."
},
"triggerEventType": {
"type": "string",
+62 -1
View File
@@ -91,6 +91,7 @@ if (fs.existsSync(guardrailsPath)) {
// Flatten categorized lists for validation
const flatRegistryPrefixes = guardrails.registryPrefixes.flatMap((g) => g.prefixes);
const flatCommands = guardrails.allowedCommands.flatMap((g) => g.commands);
const flatEventLogChannels = guardrails.eventLogChannels.flatMap((g) => g.channels);
const ts = `/**
* AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
@@ -114,6 +115,10 @@ export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
${categorizedArray(guardrails.allowedCommands, 'commands')}
];
export const EVENT_LOG_CHANNEL_CATEGORIES: readonly GuardrailCategory[] = [
${categorizedArray(guardrails.eventLogChannels, 'channels')}
];
// ---------------------------------------------------------------------------
// Flat arrays (for validation logic)
// ---------------------------------------------------------------------------
@@ -141,10 +146,66 @@ ${flatArray(guardrails.diagnosticsPathPrefixes)}
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
${flatArray(guardrails.blockedFilePrefixes)}
];
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
${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');
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes`);
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes, ${flatEventLogChannels.length} event log channels`);
} 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,362 +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-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://autopilotmonitor.blob.core.windows.net/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.1"
# 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}"
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
Write-Log "Downloaded agent to $zipPath"
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'
}
}
+358 -176
View File
@@ -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 ''
+96 -39
View File
@@ -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.
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
private const string CurrentUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
private const string TargetUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
private static AgentConfigResponse Config(string migrateUrl) =>
private static AgentConfigResponse Config(string? migrateUrl) =>
new AgentConfigResponse { MigrateToApiBaseUrl = migrateUrl };
[Fact]
@@ -285,6 +285,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
}
[Fact]
public void Merge_maps_enable_gather_rule_debug_log_true_to_default_path()
{
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = null;
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = true;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(string.IsNullOrEmpty(agent.GatherRuleDebugLogPath));
Assert.Equal(
Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath),
agent.GatherRuleDebugLogPath);
}
[Fact]
public void Merge_maps_enable_gather_rule_debug_log_false_clears_path()
{
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Null(agent.GatherRuleDebugLogPath);
}
[Fact]
public void Merge_preserves_custom_gather_rule_debug_log_path_when_remote_disables()
{
// A dev-time --gather-debug-log <custom> override seeds a non-default path on the
// AgentConfiguration. When the tenant remote flips EnableGatherRuleDebugLog off we
// keep the custom path rather than clobbering a deliberate dev override.
var agent = NewAgentConfig();
agent.GatherRuleDebugLogPath = @"C:\Temp\custom-gather.log";
var remote = FullRemote();
remote.EnableGatherRuleDebugLog = false;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(@"C:\Temp\custom-gather.log", agent.GatherRuleDebugLogPath);
}
// ============================================================= Null / partial tolerance
[Fact]
@@ -70,6 +70,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Read the file the production code actually wrote and deserialize with the same lib.
var raw = File.ReadAllText(_cachePath);
var persisted = JsonConvert.DeserializeObject<AgentConfigResponse>(raw);
Assert.NotNull(persisted);
Assert.False(persisted.UnrestrictedMode);
Assert.False(persisted.AllowAgentDowngrade);
@@ -193,7 +193,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
case BackendBehaviour.AlwaysAuthFailure401:
throw new BackendAuthException("Unauthorized", 401);
case BackendBehaviour.AlwaysReturnNull:
return Task.FromResult<AgentConfigResponse>(null);
return Task.FromResult<AgentConfigResponse>(null!);
default:
throw new InvalidOperationException("unknown behaviour");
}
@@ -204,9 +204,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// In-memory + per-instance cache so a prior test's successful fetch can't
// bleed into a later test that expects UsedDefaults.
private AgentConfigResponse _cached;
private AgentConfigResponse? _cached;
protected override void CacheConfig(AgentConfigResponse config) { _cached = config; }
protected override AgentConfigResponse LoadCachedConfig() => _cached;
protected override AgentConfigResponse LoadCachedConfig() => _cached!;
}
}
}
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
using DeviceInfoCollector = AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector;
namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
{
/// <summary>
/// Audit Q2 — <see cref="DeviceInfoCollector.RefreshEspConfiguration"/>
/// re-reads the ESP blocking lists and re-emits <c>esp_config_detected</c> exactly when the
/// payload changed: the registry lists grow progressively (one timestamped subkey per CSP
/// status write) and the user-scope lists appear only after sign-in, so the early emissions
/// are structurally partial. The StartupEventGate keeps an unchanged re-read silent.
/// Probe overrides are static slots → serialized with the other probe-driven suites.
/// </summary>
[Collection("SerialThreading")]
public sealed class DeviceInfoCollectorEspRefreshTests
{
private static readonly DateTime T0 = new DateTime(2026, 7, 27, 10, 0, 0, DateTimeKind.Utc);
private const string DeviceApp1 = "11111111-1111-1111-1111-111111111111";
private const string DeviceApp2 = "22222222-2222-2222-2222-222222222222";
private const string UserApp1 = "33333333-3333-3333-3333-333333333333";
private static EspTrackingInfoSnapshot Snapshot(string[] deviceApps, string[] userApps)
{
var all = deviceApps.Concat(userApps).Distinct().ToArray();
return new EspTrackingInfoSnapshot(
msiProductCodes: Array.Empty<string>(),
modernAppPfns: Array.Empty<string>(),
win32AppIds: all,
userWin32AppIds: userApps,
msiCount: 0,
modernCount: 0,
win32Count: all.Length);
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> EspConfigEvents(FakeSignalIngressSink ingress) =>
ingress.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.EspConfigDetected)
.ToList();
private static IReadOnlyList<string> TypedList(FakeSignalIngressSink.PostedSignal signal, string key)
{
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
}
[Fact]
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var ingress = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var collector = new DeviceInfoCollector(
"session-1", "tenant-1",
new InformationalEventPost(ingress, clock), logger,
signalIngress: null, clock: clock,
startupGate: new StartupEventGate(tmp.Path, logger));
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
_ => new EspFirstSyncSnapshot(
skipUser: false, skipDevice: false,
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
// Early read (agent start / DeviceSetup detection): partial device list, empty user list.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
{
collector.CollectEspConfiguration();
}
var afterEarly = EspConfigEvents(ingress);
Assert.Single(afterEarly);
Assert.Empty(TypedList(afterEarly[0], "espTrackedUserWin32AppIds"));
// Apps sub-phase opened: the device list grew and sign-in populated the user list.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
{
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsUser);
}
var afterGrowth = EspConfigEvents(ingress);
Assert.Equal(2, afterGrowth.Count);
Assert.Contains(DeviceApp2, TypedList(afterGrowth[1], "espTrackedWin32AppIds"));
Assert.Equal(new[] { UserApp1 }, TypedList(afterGrowth[1], "espTrackedUserWin32AppIds"));
// A later trigger with an UNCHANGED registry state re-reads but emits nothing —
// the StartupEventGate suppresses the identical payload.
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
{
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAccountSetup);
}
Assert.Equal(2, EspConfigEvents(ingress).Count);
}
[Fact]
public async Task Concurrent_refreshes_never_emit_an_older_snapshot_after_a_newer_one()
{
// Codex review: the refresh triggers each run on their own Task.Run — without the
// collector-side lock, two collects can interleave read-before-emit and the LATER
// emission carries the OLDER (smaller) list. The backend keeps the latest
// list-bearing emission as positive evidence, so that would silently shrink it.
// The probe returns a strictly growing list per read; with the read→emit critical
// section, emission order must equal read order — sizes strictly ascending.
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
var ingress = new FakeSignalIngressSink();
var clock = new VirtualClock(T0);
var collector = new DeviceInfoCollector(
"session-1", "tenant-1",
new InformationalEventPost(ingress, clock), logger,
signalIngress: null, clock: clock,
startupGate: new StartupEventGate(tmp.Path, logger));
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
_ => new EspFirstSyncSnapshot(
skipUser: false, skipDevice: false,
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
var readCounter = 0;
using var tracking = new EspTrackingInfoProbe.ScopedOverride(_ =>
{
var n = Interlocked.Increment(ref readCounter);
var ids = Enumerable.Range(1, n)
.Select(i => $"{i:d8}-1111-2222-3333-444444444444")
.ToArray();
return Snapshot(ids, Array.Empty<string>());
});
var tasks = Enumerable.Range(0, 12)
.Select(i => Task.Run(() => collector.RefreshEspConfiguration($"stress-{i}")))
.ToArray();
await Task.WhenAll(tasks);
var sizes = EspConfigEvents(ingress)
.Select(e => TypedList(e, "espTrackedWin32AppIds").Count)
.ToList();
// Every refresh saw a strictly larger list (gate never suppresses) and each
// read→emit is atomic, so the emission sequence is exactly 1..12.
Assert.Equal(Enumerable.Range(1, 12), sizes);
}
}
}
@@ -39,6 +39,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
logFolder: Tmp.Path,
patterns: new List<ImeLogPattern>(),
logger: Logger);
// Mirror ImeLogHost wiring: the historic-replay guard judges staleness against the
// (virtual) agent clock so tests stay deterministic.
Tracker.UtcNowProvider = () => Clock.UtcNow;
}
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> DecisionSignals(DecisionSignalKind kind) =>
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
logger: logger,
helloTracker: null);
EspExitedEventArgs captured = null;
EspExitedEventArgs? captured = null;
tracker.EspExited += (_, args) => captured = args;
// Source time IS in the past relative to the clock — verify the tracker carries the
@@ -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()
{
@@ -0,0 +1,71 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
{
/// <summary>
/// LocalAdminAnalyzer.MatchesAllowedEntry — the allowed-list matching contract.
/// Entries without wildcard characters match exactly (case-insensitive); entries
/// containing * or ? are glob patterns with the same semantics as the hardware
/// whitelist. The same matcher is applied to local accounts and profile folders.
/// </summary>
public sealed class LocalAdminAnalyzerTests
{
// -------------------------------------------------------------- literal entries
[Theory]
[InlineData("SupportAdmin", "SupportAdmin")]
[InlineData("supportadmin", "SupportAdmin")]
[InlineData("SUPPORTADMIN", "supportadmin")]
public void Literal_entry_matches_exactly_case_insensitive(string name, string entry)
{
Assert.True(LocalAdminAnalyzer.MatchesAllowedEntry(name, entry));
}
[Theory]
[InlineData("SupportAdmin2", "SupportAdmin")]
[InlineData("SupportAdmin", "SupportAdmin2")]
[InlineData("Support", "SupportAdmin")]
public void Literal_entry_does_not_substring_match(string name, string entry)
{
Assert.False(LocalAdminAnalyzer.MatchesAllowedEntry(name, entry));
}
// -------------------------------------------------------------- glob patterns
[Theory]
[InlineData("adm-12345", "adm-*")]
[InlineData("adm-", "adm-*")]
[InlineData("ADM-9F3B2C", "adm-*")]
[InlineData("lapsAdmin01", "lapsAdmin??")]
[InlineData("svc-backup-01", "svc-*-01")]
public void Wildcard_entry_matches_glob(string name, string entry)
{
Assert.True(LocalAdminAnalyzer.MatchesAllowedEntry(name, entry));
}
[Theory]
[InlineData("administrator2", "adm-*")] // no literal "adm-" prefix
[InlineData("xadm-123", "adm-*")] // pattern is anchored at start
[InlineData("lapsAdmin1", "lapsAdmin??")] // ? is exactly one char
[InlineData("adm.123", "adm-*")] // "-" is literal, not a regex class
public void Wildcard_entry_does_not_overmatch(string name, string entry)
{
Assert.False(LocalAdminAnalyzer.MatchesAllowedEntry(name, entry));
}
[Fact]
public void Regex_metacharacters_in_entry_are_treated_literally()
{
// "adm.123" as an entry has no wildcard chars → exact match only,
// the dot must not act as a regex any-char.
Assert.True(LocalAdminAnalyzer.MatchesAllowedEntry("adm.123", "adm.123"));
Assert.False(LocalAdminAnalyzer.MatchesAllowedEntry("admX123", "adm.123"));
// Mixed: wildcard present, remaining metacharacters stay literal.
Assert.True(LocalAdminAnalyzer.MatchesAllowedEntry("adm.123", "adm.*"));
Assert.False(LocalAdminAnalyzer.MatchesAllowedEntry("admX123", "adm.*"));
}
}
}
@@ -405,5 +405,144 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
var started = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorStarted);
Assert.Equal(0, started.Data["noCandidateThresholdMinutes"]);
}
// ============================================================================
// OOBE-state flip (oobe_state_completed) — observational one-shot on the poll
// ============================================================================
// The WinRT OutOfBoxExperienceState flip in_progress->completed is an owner-
// independent desktop corroboration for DAD-starved sessions (4d5a0b78 class).
// Tests drive polls synchronously via PollOnceForTest after Stop() so the real
// timer cannot race, and pin the owner seams to an excluded user so a real
// explorer.exe on the test machine can never set _desktopArrived (which would
// short-circuit PollForDesktop before the flip check).
private static DesktopArrivalDetector BuildFlipDetector(TempDirectory tmp, TraceCapture trace, System.Func<string> oobeProvider)
{
var detector = new DesktopArrivalDetector(new AgentLogger(tmp.Path, AgentLogLevel.Info), noCandidateTimeoutMinutes: 0)
{
OnTraceEvent = trace.Sink,
OobeStateProvider = oobeProvider,
SessionOwnerResolver = _ => "SYSTEM",
ProcessOwnerResolver = _ => "SYSTEM",
};
return detector;
}
/// <summary>Returns the queued states in order, then repeats the last one forever.</summary>
private static System.Func<string> StateQueue(params string[] states)
{
var queue = new Queue<string>(states);
var last = states[states.Length - 1];
return () => queue.Count > 0 ? queue.Dequeue() : last;
}
[Fact]
public void OobeFlip_emits_exactly_once_with_payload()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
// Baseline sample happens synchronously inside Start()/ArmTimer.
using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "completed"));
detector.Start();
detector.Stop();
detector.PollOnceForTest(); // reads "completed" -> flip
detector.PollOnceForTest(); // still "completed" -> no second emit
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
var flip = trace.Events.Find(e => e.EventType == SharedEventTypes.OobeStateCompleted);
Assert.Equal("in_progress", flip.Data["previousState"]);
Assert.True(flip.Data.ContainsKey("pollCount"));
Assert.True(flip.Data.ContainsKey("minutesSinceStart"));
Assert.False((bool)flip.Data["desktopArrived"]);
Assert.True(flip.Data.ContainsKey("wmiErrorCount"));
Assert.True(flip.Data.ContainsKey("wtsErrorCount"));
}
[Fact]
public void OobeFlip_does_not_emit_when_state_is_already_completed()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
using var detector = BuildFlipDetector(tmp, trace, StateQueue("completed"));
detector.Start();
detector.Stop();
detector.PollOnceForTest();
detector.PollOnceForTest();
// Productive machines read "completed" from the very first sample — the flip
// event must never fire there (the agent_started field already records the state).
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
}
[Fact]
public void OobeFlip_does_not_emit_when_in_progress_was_never_seen()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
using var detector = BuildFlipDetector(tmp, trace, StateQueue("unavailable", "completed"));
detector.Start();
detector.Stop();
detector.PollOnceForTest();
// Old builds without the WinRT contract report "unavailable"; a later
// "completed" without a positively observed "in_progress" is not a flip.
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
}
[Fact]
public void OobeFlip_survives_transient_unavailable_between_polls()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "unavailable", "completed"));
detector.Start();
detector.Stop();
detector.PollOnceForTest(); // transient reflection failure — must NOT clear the baseline
detector.PollOnceForTest(); // "completed" -> flip still detected
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
}
[Fact]
public void OobeFlip_provider_throwing_never_emits_and_never_pollutes_wmi_error_count()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
using var detector = BuildFlipDetector(tmp, trace, () => throw new System.InvalidOperationException("boom"));
detector.Start(); // baseline sample throws -> swallowed
detector.Stop();
detector.PollOnceForTest();
detector.PollOnceForTest();
Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted));
// The flip check has its own try/catch — a throw must not fall into
// PollForDesktop's outer catch, which increments the WMI error counter the
// session-4d5a0b78 liveness diagnostics rely on. first_poll carries that counter.
var firstPoll = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorFirstPoll);
Assert.Equal(0, firstPoll.Data["wmiErrorCount"]);
}
[Fact]
public void OobeFlip_one_shot_flag_survives_ResetForRealUserSwitch()
{
using var tmp = new TempDirectory();
var trace = new TraceCapture();
using var detector = BuildFlipDetector(tmp, trace,
StateQueue("in_progress", "completed", "in_progress", "completed"));
detector.Start();
detector.Stop();
detector.PollOnceForTest(); // flip #1
detector.ResetForRealUserSwitch(); // re-arms + re-samples baseline ("in_progress")
detector.Stop();
detector.PollOnceForTest(); // "completed" again — must NOT re-emit
Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted));
}
}
}
@@ -0,0 +1,460 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Allowlist enforcement for the two collectors that used to bypass it:
/// <see cref="LogParserCollector"/> read any path the rule named — including
/// C:\Users, whose hard block every other file collector honours — and
/// <see cref="EventLogCollector"/> read any channel, including Security.
/// A tenant admin can author both through the rules API, so the agent is the
/// only place this can be enforced.
/// </summary>
public sealed class GatherRuleCollectorGuardTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
// %TEMP% itself lives under C:\Users, which is hard-blocked — the positive
// cases need a directory the guard can actually admit, so they run beside
// the test assembly instead.
private readonly string _outsideUsers = Path.Combine(
AppContext.BaseDirectory, "guard-tests-" + Guid.NewGuid().ToString("N"));
public GatherRuleCollectorGuardTests() => Directory.CreateDirectory(_outsideUsers);
public void Dispose()
{
_tmp.Dispose();
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
}
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
=> new GatherRuleContext(
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
"sess", "tenant",
evt => _events.Add(evt),
imeLogPathOverride,
new LogFilePositionTracker())
{
UnrestrictedMode = unrestricted
};
private static GatherRule Rule(string collectorType, string target,
Dictionary<string, string>? parameters = null) => new GatherRule
{
RuleId = "GATHER-GUARD-TEST",
Title = "guard test",
CollectorType = collectorType,
Target = target,
// Match the model default (empty, never null) so the collectors see the
// same shape a deserialized rule without parameters gives them.
Parameters = parameters ?? new Dictionary<string, string>(),
Trigger = "startup",
OutputEventType = "guard_test",
};
private EnrollmentEvent SingleSecurityWarning()
=> Assert.Single(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
// -------------------------------------------------------------------
// Event log channel allowlist
// -------------------------------------------------------------------
[Theory]
[InlineData("System")]
[InlineData("Application")]
[InlineData("Setup")]
[InlineData("Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin")]
[InlineData("Microsoft-Windows-AAD/Operational")]
[InlineData("Microsoft-Windows-Shell-Core/Operational")]
public void EventLogChannel_OnAllowlist_IsAllowed(string channel)
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(channel));
[Theory]
[InlineData("Security")]
[InlineData("Microsoft-Windows-PowerShell/Operational")]
[InlineData("Windows PowerShell")]
[InlineData("Microsoft-Windows-Sysmon/Operational")]
public void EventLogChannel_HardBlocked_StaysBlockedEvenUnrestricted(string channel)
{
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel, unrestrictedMode: true));
}
[Theory]
[InlineData("Microsoft-Windows-AADSomethingElse/Operational")] // prefix spoofing
[InlineData("Microsoft-Windows-TerminalServices-LocalSessionManager/Operational")]
[InlineData("")]
public void EventLogChannel_OffAllowlist_IsBlocked(string channel)
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
[Fact]
public void EventLogChannel_Null_IsBlocked()
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(null));
[Fact]
public void EventLogChannel_OffAllowlist_IsAllowedInUnrestrictedMode()
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(
"Microsoft-Windows-TerminalServices-LocalSessionManager/Operational",
unrestrictedMode: true));
[Fact]
public void EventLogCollector_SecurityChannel_EmitsSecurityWarningAndReturnsNoData()
{
var result = new EventLogCollector().Execute(Rule("eventlog", "Security"), Context());
Assert.Empty(result);
var warning = SingleSecurityWarning();
Assert.Equal(true, warning.Data["blocked"]);
Assert.Equal("Security", warning.Data["target"]);
}
[Fact]
public void EventLogCollector_SecurityChannel_StaysBlockedInUnrestrictedMode()
{
var result = new EventLogCollector().Execute(
Rule("eventlog", "Security"), Context(unrestricted: true));
Assert.Empty(result);
SingleSecurityWarning();
}
// -------------------------------------------------------------------
// Log parser file allowlist
// -------------------------------------------------------------------
private static Dictionary<string, string> TextPattern() => new Dictionary<string, string>
{
["pattern"] = "(?<line>.+)",
["format"] = "text",
["trackPosition"] = "false",
};
[Fact]
public void LogParser_UserProfilePath_IsBlockedAndEmitsSecurityWarning()
{
const string target = @"C:\Users\Public\Documents\secret.txt";
var result = new LogParserCollector().Execute(
Rule("logparser", target, TextPattern()), Context());
Assert.Null(result);
var warning = SingleSecurityWarning();
Assert.Equal(target, warning.Data["target"]);
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
}
[Fact]
public void LogParser_UserProfilePath_StaysBlockedInUnrestrictedMode()
{
// C:\Users is a hard block — unrestricted mode must not lift it.
new LogParserCollector().Execute(
Rule("logparser", @"C:\Users\Public\Documents\secret.txt", TextPattern()),
Context(unrestricted: true));
SingleSecurityWarning();
}
[Fact]
public void LogParser_OffAllowlistPath_IsBlockedEvenWhenFileExists()
{
var path = _tmp.File("evidence.log");
File.WriteAllText(path, "line one\nline two\n");
var result = new LogParserCollector().Execute(
Rule("logparser", path, TextPattern()), Context());
Assert.Null(result);
SingleSecurityWarning();
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
}
[Fact]
public void LogParser_WildcardTarget_IsBlockedByItsDirectory()
{
// Path.GetFullPath rejects '*', so the guard has to check the directory.
File.WriteAllText(_tmp.File("evidence.log"), "line one\n");
new LogParserCollector().Execute(
Rule("logparser", Path.Combine(_tmp.Path, "*.log"), TextPattern()), Context());
SingleSecurityWarning();
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
}
[Fact]
public void LogParser_UnrestrictedMode_ReadsPathOffTheAllowlist()
{
var path = Path.Combine(_outsideUsers, "evidence.log");
File.WriteAllText(path, "line one\nline two\n");
new LogParserCollector().Execute(
Rule("logparser", path, TextPattern()), Context(unrestricted: true));
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
Assert.Equal(2, _events.Count(e => e.EventType == "guard_test"));
}
[Fact]
public void LogParser_LocalImeLogPathOverride_RelaxesTheAllowlist()
{
// --ime-log-path is a local CLI flag, never remote config: the operator
// using it is already a local admin, so it relaxes the allowlist the way
// unrestricted mode does.
File.WriteAllText(Path.Combine(_outsideUsers, "AppWorkload.log"), "line one\n");
new LogParserCollector().Execute(
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
TextPattern()),
Context(imeLogPathOverride: _outsideUsers));
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
Assert.Single(_events, e => e.EventType == "guard_test");
}
// -------------------------------------------------------------------
// Command execution
// -------------------------------------------------------------------
[Fact]
public void Command_OffAllowlist_IsBlockedAndNeverRuns()
{
var result = new CommandCollector().Execute(
Rule("command_allowlisted", "Get-Content C:\\Windows\\System32\\config\\SAM"), Context());
Assert.Equal(true, result["blocked"]);
Assert.False(result.ContainsKey("output"));
SingleSecurityWarning();
}
[Fact]
public void Command_HardBlockedPattern_StaysBlockedInUnrestrictedMode()
{
var result = new CommandCollector().Execute(
Rule("command_allowlisted", "Invoke-WebRequest https://example.invalid/x"),
Context(unrestricted: true));
Assert.Equal(true, result["blocked"]);
SingleSecurityWarning();
}
[Fact]
public void Command_ThatHangs_IsKilledAtTheTimeoutRatherThanPinningTheWorker()
{
// Regression: reading stdout synchronously blocked until the pipe closed,
// so WaitForExit's timeout was never reached and a hung command pinned the
// worker indefinitely. Unrestricted mode is needed to run an ad-hoc command.
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = new CommandCollector().Execute(
Rule("command_allowlisted", "Start-Sleep -Seconds 120"), Context(unrestricted: true));
sw.Stop();
Assert.Equal(true, result["timed_out"]);
Assert.Equal(-1, result["exit_code"]);
// The 30s timeout plus drain margin — nowhere near the command's 120s.
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(60),
$"expected the hung command to be killed at the timeout; took {sw.Elapsed}");
}
// -------------------------------------------------------------------
// Every remaining collector actually calls its guard
//
// The guard functions have their own unit tests, but those prove nothing about
// whether a collector consults them before touching the registry, WMI or disk.
// These drive the collectors themselves: each blocked target must come back empty,
// emit exactly one security_warning naming the target, and produce no rule output.
// -------------------------------------------------------------------
private void AssertBlocked(Dictionary<string, object> result, string target)
{
Assert.Empty(result);
var warning = SingleSecurityWarning();
Assert.Equal(true, warning.Data["blocked"]);
Assert.Equal(target, warning.Data["target"]);
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
}
[Theory]
[InlineData(@"SAM\SAM\Domains\Account\Users")]
[InlineData(@"SECURITY\Policy\Secrets")]
[InlineData(@"HKLM\SOFTWARE\Microsoft\EnrollmentsSomethingElse")] // prefix spoofing
[InlineData(@"HKLM\SOFTWARE\Microsoft\Windows\AutopilotSomethingElse")] // sibling spoof of the Autopilot prefix
public void RegistryCollector_OffAllowlistPath_IsBlockedBeforeTheHiveIsOpened(string target)
=> AssertBlocked(new RegistryCollector().Execute(Rule("registry", target), Context()), target);
// Session 5d735290: the EnrollmentStatusTracking subtree (ESP policy providers / tracking
// state) is allow-listed so tenants can build gather rules on the co-management stall
// signature the esp_policy_provider_stalled detector reports (e.g. not_exists on the
// Sidecar provider key).
[Theory]
[InlineData(@"SOFTWARE\Microsoft\Windows\Autopilot")]
[InlineData(@"SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup\Apps\PolicyProviders\Sidecar")]
public void RegistryGuard_AutopilotEnrollmentTrackingSubtree_IsAllowed(string subPath)
=> Assert.True(GatherRuleGuards.IsRegistryPathAllowed(subPath));
[Theory]
[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()
{
const string target = @"C:\Users\Public\Documents\secrets.json";
AssertBlocked(
new JsonCollector().Execute(
Rule("json", target, new Dictionary<string, string> { ["jsonpath"] = "$.token" }),
Context()),
target);
}
[Fact]
public void XmlCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
const string target = @"C:\Users\Public\Documents\secrets.xml";
AssertBlocked(
new XmlCollector().Execute(
Rule("xml", target, new Dictionary<string, string> { ["xpath"] = "//token" }),
Context()),
target);
}
[Fact]
public void FileCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
const string target = @"C:\Users\Public\Documents\secrets.txt";
AssertBlocked(new FileCollector().Execute(Rule("file", target), Context()), target);
}
[Fact]
public void JsonCollector_AllowedPath_ReadsTheFile()
{
// Counterpart to the blocked cases: proves the guard is a gate, not a wall —
// the collector does reach the file once the path is admitted.
var path = Path.Combine(_outsideUsers, "inventory.json");
File.WriteAllText(path, "{ \"agent\": { \"version\": \"2.4.0\" } }");
var result = new JsonCollector().Execute(
Rule("json", path, new Dictionary<string, string> { ["jsonpath"] = "$.agent.version" }),
Context(unrestricted: true));
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
Assert.Contains("2.4.0", string.Join(",", result.Values));
}
// -------------------------------------------------------------------
// Path traversal
// -------------------------------------------------------------------
[Fact]
public void FilePath_TraversalOutOfAnAllowedPrefix_IsBlocked()
{
// The raw string starts with an allowed prefix, so a naive StartsWith check would
// admit it. Path.GetFullPath runs FIRST, so the allowlist sees C:\Secret.txt.
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Secret.txt";
Assert.Equal(@"C:\Secret.txt", Path.GetFullPath(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
}
[Fact]
public void FilePath_TraversalIntoTheUsersHardBlock_StaysBlockedEvenUnrestricted()
{
// Unrestricted mode lifts the allowlist but never the C:\Users privacy block —
// and it must not be reachable by spelling the path with '..' either.
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
Assert.StartsWith(@"C:\Users\", Path.GetFullPath(traversal), StringComparison.OrdinalIgnoreCase);
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
}
[Fact]
public void FilePath_TraversalIntoTheConfigHiveDirectory_StaysBlockedEvenUnrestricted()
{
const string traversal = @"C:\Windows\Logs\..\System32\config\SAM";
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
}
[Fact]
public void FileCollector_TraversalTarget_IsBlockedAndReported()
{
const string traversal =
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
AssertBlocked(new FileCollector().Execute(Rule("file", traversal), Context()), traversal);
}
[Fact]
public void LogParser_ImeLogPathOverride_StillHonoursTheUsersHardBlock()
{
new LogParserCollector().Execute(
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
TextPattern()),
Context(imeLogPathOverride: @"C:\Users\Public\Documents"));
SingleSecurityWarning();
}
}
}
@@ -0,0 +1,104 @@
using System;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Writer unit tests for <see cref="GatherRuleDebugLog"/>: line format, lazy directory
/// creation, size-cap rotation, and the null-writer no-op path on the context.
/// </summary>
public sealed class GatherRuleDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
public GatherRuleDebugLogTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose() => _tmp.Dispose();
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
[Fact]
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
Assert.False(File.Exists(LogPath)); // lazy — nothing until the first write
log.Write("RULE-1", GatherRuleDebugLog.StageScope, "skipped: out of scope");
var lines = File.ReadAllLines(LogPath);
Assert.Equal(2, lines.Length); // header + entry
Assert.Contains("| - | config | -- gather rule debug log started --", lines[0]);
var parts = lines[1].Split(new[] { " | " }, StringSplitOptions.None);
Assert.Equal(4, parts.Length);
Assert.Equal("RULE-1", parts[1]);
Assert.Equal("scope", parts[2]);
Assert.Equal("skipped: out of scope", parts[3]);
}
[Fact]
public void Write_flattens_newlines_except_for_error_stage()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
log.Write("RULE-1", GatherRuleDebugLog.StageCollector, "line1\r\nline2");
log.Write("RULE-1", GatherRuleDebugLog.StageError, "boom\r\n at Frame()");
var content = File.ReadAllText(LogPath);
Assert.Contains("line1 line2", content);
Assert.Contains(" at Frame()", content); // error stage keeps the stack multi-line
}
[Fact]
public void Write_null_ruleId_renders_dash()
{
var log = new GatherRuleDebugLog(LogPath, _logger);
log.Write(null, GatherRuleDebugLog.StageConfig, "no rules delivered");
Assert.Contains(" | - | config | no rules delivered", File.ReadAllText(LogPath));
}
[Fact]
public void Write_rotates_once_when_file_exceeds_cap()
{
var log = new GatherRuleDebugLog(LogPath, _logger, maxBytes: 512);
for (int i = 0; i < 20; i++)
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
Assert.True(File.Exists(LogPath));
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
// Never more than one .old generation
Assert.Single(Directory.GetFiles(Path.GetDirectoryName(LogPath), "*.old.log"));
}
[Fact]
public void WriteStandalone_writes_single_config_line()
{
GatherRuleDebugLog.WriteStandalone(LogPath, "no gather rules delivered by backend — nothing to execute", _logger);
var lines = File.ReadAllLines(LogPath);
Assert.Contains(lines, l => l.Contains("no gather rules delivered by backend"));
}
[Fact]
public void Context_DebugLog_is_noop_without_writer()
{
var context = new GatherRuleContext(
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
Assert.False(File.Exists(LogPath));
}
}
}
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Debug-trace coverage of <see cref="GatherRuleExecutor"/> (EnableGatherRuleDebugLog):
/// every "silently produced nothing" outcome must leave an explanatory line in the trace
/// file — registration summary, missing interval, scope skips, on_change suppression,
/// empty collector results.
/// </summary>
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly GatherRuleExecutor _executor;
private readonly string _debugLogPath;
private const string AbsentPath =
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000";
public GatherRuleExecutorDebugLogTests()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
_executor = new GatherRuleExecutor(
"sess", "tenant", _ => { }, logger,
debugLogPath: _debugLogPath)
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_executor.Dispose();
_tmp.Dispose();
}
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
private static GatherRule Rule(string id, Action<GatherRule>? mutate = null)
{
var rule = new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "registry",
Target = AbsentPath,
Trigger = "startup",
OutputEventType = "gather_test",
Enabled = true,
};
mutate?.Invoke(rule);
return rule;
}
[Fact]
public void UpdateRules_writes_registration_summary_per_rule()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-REG-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; }),
Rule("GATHER-REG-002", r => r.Enabled = false),
});
_executor.WaitForStartupRules(30);
var trace = TraceContent();
Assert.Contains("GATHER-REG-001 | config | registered: trigger=interval, collector=registry", trace);
Assert.Contains("emitMode=on_change", trace);
Assert.Contains("interval=3600s", trace);
Assert.Contains("GATHER-REG-002 | config | rule disabled — will never run", trace);
Assert.Contains("interval timer scheduled every 3600s (first run after one full interval", trace);
}
[Fact]
public void UpdateRules_flags_interval_rule_without_intervalSeconds()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-NOINT-001", r => { r.Trigger = "interval"; r.IntervalSeconds = null; }),
});
Assert.Contains("GATHER-NOINT-001 | error | trigger=interval but intervalSeconds is missing — rule will NEVER run",
TraceContent());
}
[Fact]
public void ScopedRule_skip_traces_unknown_phase_reason()
{
var rule = Rule("GATHER-SCOPE-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.ActivePhases = new List<string> { "DeviceSetup" }; });
_executor.UpdateRules(new List<GatherRule> { rule });
// Synchronous internal check mirrors the timer-tick gate; the reason must name Unknown.
string reason;
Assert.False(_executor.IsRuleInScope(rule, out reason));
Assert.Contains("currentPhase=Unknown", reason);
}
[Fact]
public void ScopedStartupRule_deferral_is_traced()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-DEFER-001", r => r.ActivePhases = new List<string> { "DeviceSetup" }),
});
Assert.Contains("GATHER-DEFER-001 | scope | startup rule deferred — currentPhase=Unknown", TraceContent());
}
[Fact]
public void OnChange_suppression_and_reemission_are_traced()
{
var rule = Rule("GATHER-OC-001", r => { r.Trigger = "interval"; r.IntervalSeconds = 3600; r.EmitMode = "on_change"; });
_executor.UpdateRules(new List<GatherRule> { rule });
string hashPrefix;
int streak;
var first = new Dictionary<string, object> { ["exists"] = false };
Assert.True(_executor.ShouldEmitOnChange(rule, first, out hashPrefix, out streak));
Assert.Equal(0, streak);
Assert.False(string.IsNullOrEmpty(hashPrefix));
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
Assert.Equal(1, streak);
Assert.False(_executor.ShouldEmitOnChange(rule, new Dictionary<string, object> { ["exists"] = false }, out hashPrefix, out streak));
Assert.Equal(2, streak);
var changed = new Dictionary<string, object> { ["exists"] = true };
Assert.True(_executor.ShouldEmitOnChange(rule, changed, out hashPrefix, out streak));
Assert.Equal(2, streak); // the streak that just ended
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()
{
_executor.UpdateRules(new List<GatherRule>
{
Rule("GATHER-EMPTY-001", r => r.Parameters = new Dictionary<string, string> { ["emitOnlyIfExists"] = "true" }),
});
Assert.True(_executor.WaitForStartupRules(30));
var trace = TraceContent();
Assert.Contains("GATHER-EMPTY-001 | exec | executing: collector=registry", trace);
Assert.Contains("GATHER-EMPTY-001 | collector | registry key/value not found", trace);
Assert.Contains("GATHER-EMPTY-001 | collector | collector returned EMPTY result — nothing emitted", trace);
}
}
}
@@ -192,7 +192,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "1" }),
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = 1 }));
Assert.NotEqual(
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null }),
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = null! }),
GatherRuleExecutor.ComputeCanonicalHash(new Dictionary<string, object> { ["v"] = "<null>" }));
}
@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// The <c>phase_exit</c> trigger: a one-shot collection when a phase is LEFT — the closing
/// bookend to <c>phase_change</c> (which fires on ENTER). The distinguishing invariant is that
/// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being
/// left, i.e. before the executor's current-phase state moves forward.
/// Rules use the absent-registry-path pattern so no real system state is touched.
/// </summary>
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorPhaseExitTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly object _eventsGate = new object();
private readonly GatherRuleExecutor _executor;
private const string AbsentPath =
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_9c4d2a7f-0000-0000-0000-000000000000";
public GatherRuleExecutorPhaseExitTests()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_executor = new GatherRuleExecutor(
"sess", "tenant",
evt => { lock (_eventsGate) _events.Add(evt); },
logger)
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_executor.Dispose();
_tmp.Dispose();
}
private int EventCount { get { lock (_eventsGate) return _events.Count; } }
private bool WaitForEventCount(int count, int timeoutMs = 10000)
{
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (EventCount >= count) return true;
Thread.Sleep(25);
}
return EventCount >= count;
}
private static GatherRule ExitRule(
string id, string triggerPhase, List<string>? activePhases = null, string? fromPhase = null)
=> new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "registry",
Target = AbsentPath,
Trigger = "phase_exit",
TriggerPhase = triggerPhase,
OutputEventType = "gather_test",
Enabled = true,
ActivePhases = activePhases,
ActiveFromPhase = fromPhase,
};
[Fact]
public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered()
{
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-001", "AccountSetup") });
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
Thread.Sleep(300);
Assert.Equal(0, EventCount);
// Entering AccountSetup must NOT fire an exit rule.
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
Thread.Sleep(300);
Assert.Equal(0, EventCount);
// Leaving it does.
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Assert.True(WaitForEventCount(1));
}
[Fact]
public void DoesNotRefireWhenThePhaseIsLeftTwice()
{
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-002", "AccountSetup") });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Assert.True(WaitForEventCount(1));
// A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed.
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Thread.Sleep(400);
Assert.Equal(1, EventCount);
}
[Fact]
public void EmptyTriggerPhase_FiresOnEveryExit()
{
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-003", "") });
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire
Thread.Sleep(300);
Assert.Equal(0, EventCount);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup
Assert.True(WaitForEventCount(1));
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup
Assert.True(WaitForEventCount(2));
}
[Fact]
public void NeverFiresOnExitOfUnknown()
{
// Before the first phase signal there is no phase to leave.
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-004", "") });
_executor.OnPhaseChanged(EnrollmentPhase.Start);
Thread.Sleep(300);
Assert.Equal(0, EventCount);
}
[Fact]
public void RepeatedSamePhaseSignal_IsNotAnExit()
{
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-005", "") });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition
Thread.Sleep(300);
Assert.Equal(0, EventCount);
}
[Fact]
public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint()
{
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-006", "AccountSetup") });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.Failed);
Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it");
}
[Fact]
public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft()
{
// "End of enrollment" is better served by phase_change on Complete / on_event; an exit
// rule on Complete only fires if a later transition happens at all.
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-007", "Complete") });
_executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup);
_executor.OnPhaseChanged(EnrollmentPhase.Complete);
Thread.Sleep(300);
Assert.Equal(0, EventCount);
}
[Fact]
public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft()
{
// Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in
// scope at that instant) even though the NEW phase is outside the scope.
_executor.UpdateRules(new List<GatherRule>
{
ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List<string> { "DeviceSetup" })
});
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
Assert.True(WaitForEventCount(1),
"the scope gate must see the phase being left, not the one being entered");
}
[Fact]
public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot()
{
// Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope.
var rule = ExitRule("GATHER-PX-011", "", activePhases: new List<string> { "AccountSetup" });
_executor.UpdateRules(new List<GatherRule> { rule });
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope
Thread.Sleep(300);
Assert.Equal(0, EventCount);
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope
Assert.True(WaitForEventCount(1));
}
[Fact]
public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently()
{
// The dedup key spaces must not collide: same ruleId shape, same phase, different trigger.
var enter = new GatherRule
{
RuleId = "GATHER-PX-020",
Title = "enter",
CollectorType = "registry",
Target = AbsentPath,
Trigger = "phase_change",
TriggerPhase = "AccountSetup",
OutputEventType = "gather_test",
Enabled = true,
};
var exit = ExitRule("GATHER-PX-021", "AccountSetup");
_executor.UpdateRules(new List<GatherRule> { enter, exit });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
Assert.True(WaitForEventCount(1)); // enter fired
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Assert.True(WaitForEventCount(2)); // exit fired
}
[Fact]
public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult()
{
var rule = ExitRule("GATHER-PX-030", "AccountSetup");
rule.EmitMode = "on_change";
_executor.UpdateRules(new List<GatherRule> { rule });
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
Assert.True(WaitForEventCount(1));
}
}
}
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
/// <summary>Interval rule with a 1h period — the timer never ticks within a test run,
/// so these rules exist purely for synchronous scope-matrix assertions.</summary>
private static GatherRule ScopeProbeRule(string id, List<string> activePhases = null, string fromPhase = null)
private static GatherRule ScopeProbeRule(string id, List<string>? activePhases = null, string? fromPhase = null)
=> new GatherRule
{
RuleId = id,
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
ActiveFromPhase = fromPhase,
};
private static GatherRule StartupRule(string id, List<string> activePhases = null, string fromPhase = null)
private static GatherRule StartupRule(string id, List<string>? activePhases = null, string? fromPhase = null)
=> new GatherRule
{
RuleId = id,
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Debug-trace coverage of <see cref="LogParserCollector"/>: the classic
/// "rule delivers nothing" cases must be explained in the trace — zero matches,
/// position-tracker skips, and plain-text logs parsed in the default cmtrace mode.
/// </summary>
public sealed class LogParserCollectorDebugLogTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly GatherRuleContext _context;
private readonly string _debugLogPath;
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
// test assembly instead, admitted via UnrestrictedMode.
private readonly string _outsideUsers = Path.Combine(
AppContext.BaseDirectory, "gather-debug-tests-" + Guid.NewGuid().ToString("N"));
public LogParserCollectorDebugLogTests()
{
Directory.CreateDirectory(_outsideUsers);
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
_context = new GatherRuleContext(
logger, "sess", "tenant",
evt => _events.Add(evt),
null,
new LogFilePositionTracker(),
new GatherRuleDebugLog(_debugLogPath, logger))
{
UnrestrictedMode = true
};
}
public void Dispose()
{
_tmp.Dispose();
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
}
private string TraceContent() => File.Exists(_debugLogPath) ? File.ReadAllText(_debugLogPath) : string.Empty;
private static GatherRule Rule(string target, string pattern, string? format = null) => new GatherRule
{
RuleId = "GATHER-LP-DEBUG",
Title = "logparser debug test",
CollectorType = "logparser",
Target = target,
Parameters = format == null
? new Dictionary<string, string> { ["pattern"] = pattern }
: new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = format },
Trigger = "startup",
OutputEventType = "logparser_debug_test",
};
[Fact]
public void ZeroMatches_writes_per_file_outcome_line()
{
var path = Path.Combine(_outsideUsers, "app.log");
File.WriteAllText(path, "nothing interesting\nstill nothing\n");
new LogParserCollector().Execute(Rule(path, "WILL_NEVER_MATCH_[0-9]+", format: "text"), _context);
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("app.log: read 2 lines", trace);
Assert.Contains("matched 0", trace);
Assert.Contains("mode=text", trace);
}
[Fact]
public void SecondRun_without_new_content_traces_position_skip()
{
var path = Path.Combine(_outsideUsers, "twice.log");
File.WriteAllText(path, "one line\n");
var rule = Rule(path, "one", format: "text");
var collector = new LogParserCollector();
collector.Execute(rule, _context); // reads the file, advances the position
collector.Execute(rule, _context); // nothing new
Assert.Contains("twice.log: no new content (position", TraceContent());
}
[Fact]
public void PlainTextFile_in_default_cmtrace_mode_gets_format_hint()
{
var path = Path.Combine(_outsideUsers, "plain.log");
File.WriteAllText(path, "plain line one\nplain line two\n");
new LogParserCollector().Execute(Rule(path, "plain"), _context); // default = cmtrace mode
Assert.Empty(_events);
var trace = TraceContent();
Assert.Contains("parseFailures=2", trace);
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()
{
var path = Path.Combine(_outsideUsers, "does-not-exist.log");
new LogParserCollector().Execute(Rule(path, "x", format: "text"), _context);
Assert.Contains("no files matched target", TraceContent());
}
}
}
@@ -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
@@ -27,7 +27,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
[InlineData(" ")] // whitespace only
[InlineData("<![LOG[truncated line with no closing")] // truncated / missing fields
[InlineData("garbage prefix <![LOG[msg]LOG]!><time=\"06:08:04.8834397\" date=\"2-8-2026\" component=\"C\" context=\"\" type=\"1\" thread=\"1\" file=\"\">")] // leading junk (non-ignorable) fails the StartsWith gate
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string line)
public void TryParseLine_returns_false_without_throwing_for_malformed_input(string? line)
{
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
@@ -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]
@@ -24,12 +24,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
/// </summary>
public sealed class ImeLogPatternReDoSTests
{
private static ImeLogPattern Pattern(string id, string regex, string action = null) => new ImeLogPattern
private static ImeLogPattern Pattern(string id, string regex, string? action = null) => new ImeLogPattern
{
PatternId = id,
Pattern = regex,
Category = "always",
Action = action,
// Action is optional on the wire (absent = null); the model declares it non-nullable.
Action = action!,
Enabled = true,
Parameters = new Dictionary<string, string>(),
};
@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Tracker-level historic-replay guard (session eaf3d8c4 part 2): source lines > 24 h older
/// than now are content from a previous enrollment whose IME log survived on disk. The guard
/// in <c>HandlePatternMatch</c> skips app-mutating actions for such lines so replayed apps
/// never enter <c>_packageStates</c> / phase snapshots / persistence — keeping
/// app_tracking_summary, culprit lists and final-status clean. Script actions pass through
/// (the adapter suppresses their emissions), and SimulationMode bypasses the guard entirely.
/// </summary>
public sealed class ImeLogTrackerHistoricReplayGuardTests
{
private static readonly DateTime Now = new DateTime(2026, 7, 23, 15, 42, 0, DateTimeKind.Utc);
private static List<ImeLogPattern> TestPatterns() => new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "T-POLICIES", Category = "always", Enabled = true,
Pattern = @"policies discovered = (?<policies>\[.*\])",
Action = "policiesDiscovered",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
Pattern = @"app (?<id>[\w-]+) installed",
Action = "updateStateInstalled",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-ESP-PHASE", Category = "always", Enabled = true,
Pattern = @"In EspPhase: (?<espPhase>\w+)",
Action = "espPhaseDetected",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-SCRIPT-START", Category = "always", Enabled = true,
Pattern = @"script (?<id>[\w-]+) started",
Action = "scriptStarted",
Parameters = new Dictionary<string, string> { ["scriptType"] = "platform" },
},
};
private static ImeLogTracker BuildTracker(TempDirectory tmp)
{
var tracker = new ImeLogTracker(
logFolder: tmp.Path,
patterns: TestPatterns(),
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
tracker.UtcNowProvider = () => Now;
return tracker;
}
private const string PoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""}]";
[Fact]
public void Stale_policies_line_does_not_populate_package_states()
{
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
Assert.Empty(tracker.GetAllKnownPackageStates());
}
[Fact]
public void Fresh_relog_of_same_policies_is_tracked_after_stale_skip()
{
// IME re-logs the full policy JSON on every app-policy check-in — skipping the
// stale copy cannot starve the current enrollment.
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddMinutes(-2));
var all = tracker.GetAllKnownPackageStates();
var pkg = Assert.Single(all);
Assert.Equal("app-1", pkg.Id);
}
[Fact]
public void Stale_state_update_against_fresh_app_is_skipped()
{
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
var stateChanges = 0;
tracker.OnAppStateChanged = (_, __, ___) => stateChanges++;
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddMinutes(-5)); // fresh discovery
tracker.ProcessLogMessageForTest("app app-1 installed", Now.AddDays(-7)); // stale mutation
Assert.Equal(0, stateChanges);
var pkg = Assert.Single(tracker.GetAllKnownPackageStates());
Assert.NotEqual(AppInstallationState.Installed, pkg.InstallationState);
}
[Fact]
public void Stale_esp_phase_line_does_not_block_fresh_phase_detection()
{
// A stale "AccountSetup" would advance _currentPhaseOrder and make the fresh
// enrollment's DeviceSetup bounce as "backward" — the guard must skip it.
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
var phases = new List<string>();
tracker.OnEspPhaseChanged = p => phases.Add(p);
tracker.ProcessLogMessageForTest("In EspPhase: AccountSetup", Now.AddDays(-7));
tracker.ProcessLogMessageForTest("In EspPhase: DeviceSetup", Now.AddMinutes(-1));
Assert.Equal(new[] { "DeviceSetup" }, phases);
}
[Fact]
public void Stale_script_line_still_reaches_script_handlers()
{
// Script tracker state is harmless for stale lines (adapter suppresses the
// emissions; the stale-slot hardening covers leftovers) — the guard must not
// swallow script actions.
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
var started = new List<ScriptStartedInfo>();
tracker.OnScriptStarted = i => started.Add(i);
tracker.ProcessLogMessageForTest("script aaaa1111-0000-0000-0000-000000000000 started", Now.AddDays(-7));
Assert.Single(started);
}
[Fact]
public void SimulationMode_bypasses_the_guard()
{
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
tracker.SimulationMode = true;
tracker.ProcessLogMessageForTest(PoliciesLine, Now.AddDays(-7));
Assert.Single(tracker.GetAllKnownPackageStates());
}
[Fact]
public void Lines_without_timestamp_are_not_treated_as_stale()
{
// Non-CMTrace lines carry no source timestamp (entry == null) — never stale.
using var tmp = new TempDirectory();
using var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest(PoliciesLine, sourceTimestampUtc: null);
Assert.Single(tracker.GetAllKnownPackageStates());
}
}
}
@@ -0,0 +1,209 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the pure netsh-output parser of the WiFi fallback path. netsh labels follow
/// the OS UI language — the parser must extract SSID/signal/radio/channel from the label
/// variants of the languages commonly seen in enrollments, and degrade gracefully (fields
/// absent, never throw) on unrecognized ones.
/// </summary>
public sealed class NetshWifiFallbackTests
{
[Fact]
public void Parse_english_output_extracts_all_fields()
{
const string output = @"
There is 1 interface on the system:
Name : Wi-Fi
Description : Intel(R) Wi-Fi 6 AX201 160MHz
GUID : 754b6c07-5ae9-49d5-ba6c-4fbd6e38bbf6
State : connected
SSID : contoso-corp
AP BSSID : 3c:37:12:53:c8:44
Band : 5 GHz
Channel : 116
Radio type : 802.11ax
Signal : 94%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-corp", r!.Ssid);
Assert.Equal(94, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(116, r.Channel);
}
[Fact]
public void Parse_german_output_extracts_all_fields()
{
const string output = @"
Es ist 1 Schnittstelle auf dem System vorhanden:
Name : WLAN
Beschreibung : Intel(R) Wi-Fi 6 AX201 160MHz
Status : Verbunden
SSID : fabrikam-wlan
Kanal : 44
Funktyp : 802.11ac
Signal : 87%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("fabrikam-wlan", r!.Ssid);
Assert.Equal(87, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(44, r.Channel);
}
[Fact]
public void Parse_spanish_output_extracts_signal_and_channel()
{
const string output = @"
Nombre : Wi-Fi
SSID : contoso-es
Canal : 6
Tipo de radio : 802.11n
Señal : 72%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-es", r!.Ssid);
Assert.Equal(72, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(6, r.Channel);
}
[Fact]
public void Parse_chinese_simplified_output_extracts_all_fields()
{
const string output = @"
名称 : WLAN
SSID : contoso-cn
信道 : 149
无线电类型 : 802.11ac
信号 : 68%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-cn", r!.Ssid);
Assert.Equal(68, r.SignalPercent);
Assert.Equal("802.11ac", r.RadioType);
Assert.Equal(149, r.Channel);
}
[Fact]
public void Parse_korean_output_extracts_all_fields()
{
const string output = @"
이름 : Wi-Fi
SSID : contoso-kr
채널 : 36
라디오 종류 : 802.11ax
신호 : 91%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-kr", r!.Ssid);
Assert.Equal(91, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(36, r.Channel);
}
[Fact]
public void Parse_signal_with_space_before_percent_is_parsed()
{
var r = NetshWifiFallback.Parse(" Signal : 81 %\r\n SSID : x\r\n");
Assert.NotNull(r);
Assert.Equal(81, r!.SignalPercent);
}
[Fact]
public void Parse_ap_bssid_does_not_overwrite_ssid()
{
const string output = @"
SSID : contoso-corp
AP BSSID : aa:bb:cc:dd:ee:ff
";
var r = NetshWifiFallback.Parse(output);
Assert.Equal("contoso-corp", r!.Ssid);
}
[Fact]
public void Parse_finnish_output_extracts_all_fields()
{
const string output = @"
Nimi : Wi-Fi
SSID : contoso-fi
Kanava : 100
Radiotyyppi : 802.11ax
Signaali : 79%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-fi", r!.Ssid);
Assert.Equal(79, r.SignalPercent);
Assert.Equal("802.11ax", r.RadioType);
Assert.Equal(100, r.Channel);
}
[Fact]
public void Parse_indonesian_output_extracts_all_fields()
{
const string output = @"
Nama : Wi-Fi
SSID : contoso-id
Saluran : 11
Jenis radio : 802.11n
Sinyal : 64%
";
var r = NetshWifiFallback.Parse(output);
Assert.NotNull(r);
Assert.Equal("contoso-id", r!.Ssid);
Assert.Equal(64, r.SignalPercent);
Assert.Equal("802.11n", r.RadioType);
Assert.Equal(11, r.Channel);
}
[Fact]
public void Parse_unknown_language_without_ssid_returns_null()
{
// Greek is not in the enrollment-geography label set -> nothing to report.
var r = NetshWifiFallback.Parse(" Κατάσταση : συνδεδεμένο\r\n Σήμα : 90%\r\n");
Assert.Null(r);
}
[Fact]
public void Parse_unknown_language_still_yields_ssid()
{
// SSID is untranslated in all locales — partial result beats none.
var r = NetshWifiFallback.Parse(" SSID : contoso-gr\r\n Σήμα : 90%\r\n");
Assert.NotNull(r);
Assert.Equal("contoso-gr", r!.Ssid);
Assert.Null(r.SignalPercent);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Der WLAN-AutoKonfig-Dienst (wlansvc) wird nicht ausgeführt.")]
public void Parse_empty_or_error_output_returns_null(string? output)
{
Assert.Null(NetshWifiFallback.Parse(output));
}
}
}
@@ -0,0 +1,98 @@
#nullable enable
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop
{
/// <summary>
/// Tests for the native-WLAN-API WiFi reader. The WLAN state of the test machine is
/// unknown (CI runners/VMs have no WLAN service), so the live path only pins the
/// fail-soft contract; the pure helpers (SSID decode, PHY-type mapping) are pinned
/// exactly — their output feeds wifi_signal_info event payloads and must stay
/// consistent with the strings netsh historically reported.
/// </summary>
public sealed class WifiInfoReaderTests
{
[Fact]
public void TryGetCurrentConnection_never_throws_and_returns_sane_values()
{
var result = WifiInfoReader.TryGetCurrentConnection();
if (result != null)
{
Assert.True(result.SignalPercent.HasValue, "native API path must always carry signal quality");
Assert.InRange(result.SignalPercent.Value, 0, 100);
if (result.Channel.HasValue)
Assert.True(result.Channel.Value > 0, "channel must be positive when present");
}
}
// ----- DecodeSsid ---------------------------------------------------
[Fact]
public void DecodeSsid_decodes_utf8_bytes()
{
var raw = new byte[32];
var ssid = Encoding.UTF8.GetBytes("Contoso-Büro");
ssid.CopyTo(raw, 0);
Assert.Equal("Contoso-Büro", WifiInfoReader.DecodeSsid(raw, (uint)ssid.Length));
}
[Fact]
public void DecodeSsid_returns_null_for_empty_or_missing()
{
Assert.Null(WifiInfoReader.DecodeSsid(null, 5));
Assert.Null(WifiInfoReader.DecodeSsid(new byte[32], 0));
}
[Fact]
public void DecodeSsid_clamps_length_to_buffer_size()
{
var raw = Encoding.UTF8.GetBytes("abc");
// A corrupt native length beyond the buffer must not throw.
Assert.Equal("abc", WifiInfoReader.DecodeSsid(raw, 200));
}
[Fact]
public void DecodeSsid_uses_only_declared_length()
{
var raw = new byte[32];
Encoding.UTF8.GetBytes("HomeNet-Rest-Ignored").CopyTo(raw, 0);
Assert.Equal("HomeNet", WifiInfoReader.DecodeSsid(raw, 7));
}
// ----- PhyTypeToRadioType -------------------------------------------
// int parameters (cast inside): DOT11_PHY_TYPE is internal and may not appear in a
// public test method signature (CS0051).
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ofdm, "802.11a")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_hrdsss, "802.11b")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_erp, "802.11g")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_ht, "802.11n")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_vht, "802.11ac")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dmg, "802.11ad")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_he, "802.11ax")]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_eht, "802.11be")]
public void PhyTypeToRadioType_maps_known_generations(int phyType, string expected)
{
Assert.Equal(expected, WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
[Theory]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_unknown)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_fhss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_dsss)]
[InlineData((int)DOT11_PHY_TYPE.dot11_phy_type_irbaseband)]
[InlineData(9999)]
public void PhyTypeToRadioType_returns_null_for_legacy_or_unknown(int phyType)
{
Assert.Null(WifiInfoReader.PhyTypeToRadioType((DOT11_PHY_TYPE)phyType));
}
}
}
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
/// cadence and stops at its bound, is idempotent, the initial scan raises when binaries already
/// exist, a filesystem create raises, and Dispose cancels the re-probe.
/// </summary>
[Collection("SerialThreading")] // the bounded re-probe runs on the shared ThreadPool
public sealed class OfficeBinaryWatcherTests
{
private static readonly string[] CoreBinaries = { "WINWORD.EXE", "EXCEL.EXE", "POWERPNT.EXE", "OUTLOOK.EXE" };
@@ -0,0 +1,40 @@
#nullable enable
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// Smoke tests for the WinRT <c>SystemSetupInfo.OutOfBoxExperienceState</c> reader.
/// The actual state on the test machine is unknown (CI runners are post-OOBE; Server
/// SKUs may lack the UniversalApiContract entirely), so only the fail-soft contract
/// is pinned: never throw, always return a member of the known value set.
/// </summary>
public sealed class OobeStateReaderTests
{
[Fact]
public void Read_never_throws_and_returns_known_value()
{
var value = OobeStateReader.Read();
Assert.False(string.IsNullOrEmpty(value));
Assert.True(
value == "not_started"
|| value == "in_progress"
|| value == "completed"
|| value == OobeStateReader.Unavailable
|| value.StartsWith("unknown_"),
$"unexpected reader value '{value}'");
}
[Fact]
public void Read_is_stable_across_repeated_calls()
{
// Exercises the cached-PropertyInfo path (second call skips resolution).
var first = OobeStateReader.Read();
var second = OobeStateReader.Read();
Assert.Equal(first, second);
}
}
}
@@ -0,0 +1,208 @@
using System;
using System.Diagnostics;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Executes the generated cleanup script against REAL scratch trees with a real
/// powershell.exe — end-to-end replays of the 2026-07-22 incident mechanics that the
/// string pins in <see cref="CleanupServiceScriptTests"/> cannot prove:
/// <list type="bullet">
/// <item>a locked tree must abort WITHOUT deleting anything (old script force-deleted
/// marker + state around the locks and orphaned the binaries),</item>
/// <item>a straggler agent process (the incident's second instance) must be found by
/// NAME, killed, and the cleanup must then complete — simulated via function
/// shadowing, NEVER by launching a renamed executable (EDR isolation, see the test),</item>
/// <item>concurrent cleanup scripts must be serialized by the lock file.</item>
/// </list>
/// Timings are dialed down via the test-only BuildCleanupScript parameters so each case
/// runs in seconds. Every test uses GUID-unique process/task/tree names so nothing real
/// (a dev-box agent, a real Scheduled Task) can ever be touched.
/// </summary>
[Collection("SerialThreading")]
public class CleanupServiceScriptIntegrationTests : IDisposable
{
private readonly string _root;
private readonly string _basePath;
private readonly string _taskName = $"AmTest-NoSuchTask-{Guid.NewGuid():N}";
private readonly string _fakeProcessName = $"AmTestAgent{Guid.NewGuid().ToString("N").Substring(0, 8)}";
public CleanupServiceScriptIntegrationTests()
{
_root = Path.Combine(Path.GetTempPath(), $"am-cleanup-it-{Guid.NewGuid():N}");
_basePath = Path.Combine(_root, "AutopilotMonitor");
Directory.CreateDirectory(Path.Combine(_basePath, "Agent"));
Directory.CreateDirectory(Path.Combine(_basePath, "State"));
Directory.CreateDirectory(Path.Combine(_basePath, "Logs"));
File.WriteAllText(Path.Combine(_basePath, "Agent", "fake.exe"), "x");
File.WriteAllText(Path.Combine(_basePath, "State", "enrollment-complete.marker"), "x");
File.WriteAllText(Path.Combine(_basePath, "Logs", "agent.log"), "x");
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private static string LockPath => Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Cleanup.lock");
private string BuildScript(bool keepLogs = false, int processWaitSeconds = 1)
=> CleanupService.BuildCleanupScript(
agentProcessName: _fakeProcessName,
agentBasePath: _basePath,
keepLogs: keepLogs,
scheduledTaskName: _taskName,
rebootOnComplete: false,
initialDelaySeconds: 0,
processWaitSeconds: processWaitSeconds,
renameRetries: 2,
renameRetryDelaySeconds: 1);
/// <summary>Runs the script synchronously in a real powershell.exe. Returns the script path.</summary>
private string RunScript(string script)
{
var scriptPath = Path.Combine(_root, $"cleanup-{Guid.NewGuid():N}.ps1");
File.WriteAllText(scriptPath, script);
using (var proc = Process.Start(new ProcessStartInfo
{
FileName = SystemPaths.PowerShell,
Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true,
}))
{
Assert.True(proc.WaitForExit(90_000), "cleanup script did not finish within 90s");
}
return scriptPath;
}
[Fact]
public void Happy_path_deletes_tree_and_removes_lock_and_script()
{
var scriptPath = RunScript(BuildScript());
Assert.False(Directory.Exists(_basePath), "tree should be fully deleted");
Assert.Empty(Directory.GetFileSystemEntries(_root, "*.del-*"));
Assert.False(File.Exists(scriptPath), "script should self-delete");
Assert.False(File.Exists(LockPath), "cleanup lock should be released");
}
[Fact]
public void Locked_tree_aborts_completely_marker_and_files_survive()
{
// The incident regression: with a file locked inside the tree the OLD script
// deleted everything else (incl. the enrollment-complete marker) and left the
// binaries orphaned with the task already gone. The hardened script must treat
// the failed probe-rename as "someone still holds this" and walk away entirely.
using (File.Open(Path.Combine(_basePath, "Agent", "fake.exe"),
FileMode.Open, FileAccess.Read, FileShare.None))
{
RunScript(BuildScript());
}
Assert.True(File.Exists(Path.Combine(_basePath, "Agent", "fake.exe")), "binaries must stay in place");
Assert.True(File.Exists(Path.Combine(_basePath, "State", "enrollment-complete.marker")),
"marker must survive so the next boot retries cleanup");
Assert.Empty(Directory.GetFileSystemEntries(_root, "*.del-*"));
Assert.False(File.Exists(LockPath));
}
[Fact]
public void Straggler_agent_process_is_waited_on_and_force_killed_via_name_backstop()
{
// Incident replay of the second-instance mechanics WITHOUT spawning a process:
// an earlier version of this test copied powershell.exe under the fake agent name
// and launched it — textbook MITRE T1036.003 ("Rename Legitimate Utilities"),
// which Defender for Endpoint answered with a machine ISOLATION on the dev box
// (see tasks/lessons.md). Instead, a harness script shadows Get-Process /
// Stop-Process with functions (PowerShell resolves functions before cmdlets, so
// the dot-sourced cleanup script binds to the stubs): Get-Process reports a live
// agent-named straggler on every probe; Stop-Process records the kill. This pins
// the same contract — the script polls by NAME until the deadline, then
// force-kills by NAME before deleting — with zero EDR-visible behavior.
var probeLog = Path.Combine(_root, "probe.log");
var cleanupScriptPath = Path.Combine(_root, $"cleanup-{Guid.NewGuid():N}.ps1");
File.WriteAllText(cleanupScriptPath, BuildScript(processWaitSeconds: 2));
var harness = @"
$probeLog = 'LOG_PATH'
$global:AmProbeCount = 0
function Get-Process {
[CmdletBinding()]
param([string[]]$Name)
if ($Name -contains 'FAKE_NAME') {
$global:AmProbeCount++
Add-Content -Path $probeLog -Value ('probe ' + $global:AmProbeCount)
return [pscustomobject]@{ Name = 'FAKE_NAME'; Id = 424242 }
}
Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
}
function Stop-Process {
[CmdletBinding()]
param([Parameter(ValueFromPipeline = $true)]$InputObject, [switch]$Force)
process { Add-Content -Path $probeLog -Value ('kill ' + $InputObject.Id + ' force=' + $Force) }
}
. 'SCRIPT_PATH'
"
.Replace("LOG_PATH", probeLog)
.Replace("FAKE_NAME", _fakeProcessName)
.Replace("SCRIPT_PATH", cleanupScriptPath);
RunScript(harness);
var log = File.ReadAllLines(probeLog);
Assert.Contains("kill 424242 force=True", log);
Assert.True(Array.FindAll(log, l => l.StartsWith("probe")).Length >= 2,
"script must poll by name in the wait loop AND re-probe for the kill backstop");
Assert.False(Directory.Exists(_basePath), "after the kill the tree must be fully deleted");
Assert.Empty(Directory.GetFileSystemEntries(_root, "*.del-*"));
Assert.False(File.Exists(LockPath));
}
[Fact]
public void Fresh_foreign_lock_backs_off_without_touching_the_tree()
{
File.WriteAllText(LockPath, "held by another cleanup");
try
{
var scriptPath = RunScript(BuildScript());
Assert.True(File.Exists(Path.Combine(_basePath, "Agent", "fake.exe")),
"loser script must not touch the tree — the lock holder owns the deletion");
Assert.True(File.Exists(LockPath), "foreign lock must not be deleted by the loser");
Assert.False(File.Exists(scriptPath), "loser still self-deletes its script");
}
finally
{
try { File.Delete(LockPath); } catch { }
}
}
[Fact]
public void Stale_foreign_lock_is_taken_over_and_cleanup_proceeds()
{
File.WriteAllText(LockPath, "crashed cleanup from a previous boot");
File.SetLastWriteTime(LockPath, DateTime.Now.AddMinutes(-15));
RunScript(BuildScript());
Assert.False(Directory.Exists(_basePath), "stale lock must be taken over, cleanup must run");
Assert.False(File.Exists(LockPath));
}
[Fact]
public void KeepLogs_variant_deletes_everything_except_logs()
{
RunScript(BuildScript(keepLogs: true));
Assert.True(File.Exists(Path.Combine(_basePath, "Logs", "agent.log")), "Logs must survive");
Assert.False(Directory.Exists(Path.Combine(_basePath, "Agent")));
Assert.False(Directory.Exists(Path.Combine(_basePath, "State")));
Assert.Empty(Directory.GetFileSystemEntries(_basePath, "*.del-*"));
}
}
}
@@ -0,0 +1,135 @@
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Pins the LIFE-F7 hardening invariants of the self-destruct cleanup script
/// (incident 2026-07-22: a second agent instance held exe + loaded DLLs while the
/// old script — waiting on the launcher PID only — force-deleted around the locks
/// AFTER removing the Scheduled Task, orphaning the binaries with no retry path).
/// These are string-level pins on <see cref="CleanupService.BuildCleanupScript"/>:
/// weakening any of them silently re-introduces the orphaned-binaries failure mode.
/// </summary>
public class CleanupServiceScriptTests
{
private const string ProcessName = "AutopilotMonitor.Agent";
private const string BasePath = @"C:\ProgramData\AutopilotMonitor";
private const string TaskName = "AutopilotMonitorAgent";
private static string Script(bool keepLogs = false, bool reboot = false)
=> CleanupService.BuildCleanupScript(ProcessName, BasePath, keepLogs, TaskName, reboot);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Waits_by_process_name_not_launcher_pid(bool keepLogs)
{
var script = Script(keepLogs);
// LIFE-F7 #2: name-based wait covers marker-retry / self-update / manual instances
// and is immune to PID reuse. Wait-Process -Id saw exactly one PID and nothing else.
Assert.Contains($"Get-Process -Name '{ProcessName}'", script);
Assert.DoesNotContain("Wait-Process", script);
// Backstop: stragglers are force-killed — their images are deleted right after.
Assert.Contains("Stop-Process -Force", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Never_deletes_the_original_path(bool keepLogs)
{
var script = Script(keepLogs);
// LIFE-F7 #3: deletion happens exclusively on successfully renamed targets
// ($entry.Renamed). A Remove-Item against the original tree is what deleted
// marker + state around locked binaries in the incident.
Assert.DoesNotContain($"Remove-Item -Path '{BasePath}'", script);
Assert.Contains("Remove-Item -Path $entry.Renamed", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Task_removal_is_gated_on_probe_and_deletion_on_task_gone(bool keepLogs)
{
var script = Script(keepLogs);
// Probe-rename first; only a successful probe reaches task removal, and only a
// removed task reaches deletion. Both failure branches rename BACK so the next
// boot retries with task + marker + files intact.
Assert.Contains("if ($probeOk)", script);
Assert.Contains("if ($taskGone)", script);
Assert.Contains("Rename-Item -Path $entry.Renamed -NewName $entry.Original", script);
// LIFE-F6 task-removal loop + module-independent fallback stay intact.
Assert.Contains("Unregister-ScheduledTask -TaskName $taskName", script);
Assert.Contains("schtasks.exe /Delete /TN $taskName /F", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Concurrent_cleanup_scripts_are_serialized_by_lock_file(bool keepLogs)
{
var script = Script(keepLogs);
// LIFE-F7 #1: CreateNew is the atomic single-flight gate; the loser exits without
// touching the tree. Stale locks (>10 min) are taken over.
Assert.Contains("AutopilotMonitor-Cleanup.lock", script);
Assert.Contains("[System.IO.FileMode]::CreateNew", script);
Assert.Contains("$lockAge.TotalMinutes -gt 10", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Rename_targets_carry_a_guid_suffix(bool keepLogs)
{
// A fixed '.del' target collides with leftovers of earlier attempts and with a
// concurrent script's rename — the GUID makes every attempt's target unique.
Assert.Contains(".del-' + [Guid]::NewGuid().ToString('N')", Script(keepLogs));
}
[Fact]
public void KeepLogs_variant_excludes_logs_directory()
{
var script = Script(keepLogs: true);
Assert.Contains("-Exclude 'Logs'", script);
Assert.DoesNotContain($"Rename-Item -Path '{BasePath}' -NewName", script);
}
[Fact]
public void Full_variant_renames_the_whole_tree()
{
var script = Script(keepLogs: false);
Assert.Contains($"Rename-Item -Path '{BasePath}' -NewName $renamedPath", script);
Assert.DoesNotContain("-Exclude 'Logs'", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Reboot_flag_controls_shutdown_invocation(bool reboot)
{
var script = Script(keepLogs: false, reboot: reboot);
if (reboot)
Assert.Contains("shutdown.exe /r /t 10", script);
else
Assert.DoesNotContain("shutdown.exe", script);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Script_and_lock_are_released_in_finally(bool keepLogs)
{
var script = Script(keepLogs);
Assert.Contains("finally", script);
Assert.Contains("$lockStream.Dispose()", script);
Assert.Contains("Remove-Item -Path $scriptPath -Force", script);
}
}
}
@@ -226,6 +226,95 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
return archive.Entries.Select(e => e.FullName).ToArray();
}
private static string ZipEntryText(byte[] zipBytes, string entryName)
{
using var ms = new MemoryStream(zipBytes);
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
var entry = archive.GetEntry(entryName);
Assert.NotNull(entry);
using var reader = new StreamReader(entry!.Open(), Encoding.UTF8);
return reader.ReadToEnd();
}
// ── Nullable outcome (on-demand mid-session) — gate + sessioninfo semantics ──
/// <summary>
/// Sentinel subclass: throws before any HTTP so gate-pass-through is observable as a
/// non-null error result (a skip returns null BEFORE the archive is ever built).
/// </summary>
private sealed class ThrowOnBuildService : DiagnosticsPackageService
{
public ThrowOnBuildService(AgentConfiguration cfg, AgentLogger logger, BackendApiClient api)
: base(cfg, logger, api) { }
internal override byte[] BuildArchiveBytes(bool? enrollmentSucceeded) =>
throw new System.InvalidOperationException("gate-passed-sentinel");
}
private static DiagnosticsPackageService BuildGateProbe(AgentLogger logger, string mode)
{
var cfg = Cfg();
cfg.DiagnosticsUploadEnabled = true;
cfg.DiagnosticsUploadMode = mode;
var apiClient = new BackendApiClient(
httpClient: new System.Net.Http.HttpClient(),
baseUrl: "http://localhost",
manufacturer: string.Empty,
model: string.Empty,
serialNumber: string.Empty,
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "0.0.0",
logger: logger);
return new ThrowOnBuildService(cfg, logger, apiClient);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_passes_OnFailure_gate()
{
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
// there is no known success to skip on. Reaching the archive build (sentinel
// throw → error result) proves the gate was passed; a skip would return null.
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
Assert.NotNull(result);
Assert.Equal("gate-passed-sentinel", result!.ErrorCode);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_known_success_still_skipped_by_OnFailure_gate()
{
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: true);
Assert.Null(result);
}
[Fact]
public async System.Threading.Tasks.Task CreateAndUploadAsync_null_outcome_still_blocked_by_mode_Off()
{
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "Off")
.CreateAndUploadAsync(enrollmentSucceeded: null);
Assert.Null(result);
}
[Theory]
[InlineData(true, "Succeeded")]
[InlineData(false, "Failed")]
[InlineData(null, "In Progress")]
public void BuildArchiveBytes_sessioninfo_reflects_nullable_outcome(bool? outcome, string expected)
{
using var rig = new Rig();
var bytes = rig.Build().BuildArchiveBytes(outcome);
var sessionInfo = ZipEntryText(bytes, "sessioninfo.txt");
Assert.Contains($"Enrollment Result: {expected}", sessionInfo);
}
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
[Theory]
@@ -256,7 +256,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
[InlineData(null, false)]
[InlineData("", false)]
[InlineData("0x8007", false)]
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string hresult, bool expected)
public void IsAppxFamilyHresult_MatchesOnlyAppxDeploymentRange(string? hresult, bool expected)
{
Assert.Equal(expected, AppxFailureAnalyzer.IsAppxFamilyHresult(hresult));
}
@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Session 4910a5a5 (2026-07-23) — post-terminal recovery story. Field sequence:
/// DeviceSetup/Apps failed (culprit app never started installing) → settle window expired
/// → terminal EspFailureDetected → the user pressed "Try again" ~23 min later → Apps left
/// the failed state → 10/10 installed → DeviceSetup resolved. Pre-fix, that recovery only
/// appeared as a generic <c>subcategory_state_change</c>. Coverage:
/// <list type="bullet">
/// <item><c>esp_failure_retry_detected</c> fires once when the failed subcategory leaves
/// the failed state AFTER the terminal fire.</item>
/// <item><c>esp_failure_recovered</c> fires once when the category then resolves to
/// success.</item>
/// <item>An in-settle-window recovery keeps emitting only
/// <c>esp_failure_settle_recovered</c> — the post-terminal pair stays silent.</item>
/// <item>Apps failures carry <c>LikelyCulpritApps</c> on the EspFailureDetected args,
/// never-started apps ranked first.</item>
/// </list>
/// </summary>
public sealed class EspFailureRecoveryStoryTests
{
private static readonly DateTime Fixed = new DateTime(2026, 7, 23, 4, 30, 0, DateTimeKind.Utc);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink();
public List<EspFailureDetectedEventArgs> EspFailures { get; } = new List<EspFailureDetectedEventArgs>();
public ProvisioningStatusTracker Tracker { get; }
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
{
var clock = new VirtualClock(Fixed);
var post = new InformationalEventPost(Sink, clock);
var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Tracker = new ProvisioningStatusTracker(
sessionId: "S1",
tenantId: "T1",
post: post,
logger: logger,
appxScanner: new FakeAppxDeploymentFailureScanner(),
backgroundDispatcher: action => action(),
packageStatesProbe: packageStatesProbe);
Tracker.EspFailureDetected += (_, args) => EspFailures.Add(args);
}
public IReadOnlyList<(string EventType, IReadOnlyDictionary<string, object> Data)> CapturedEvents()
{
return Sink.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null)
.Select(p =>
{
var eventType = p.Payload!.TryGetValue(SignalPayloadKeys.EventType, out var et) ? et : "";
var data = p.TypedPayload as IReadOnlyDictionary<string, object>
?? (IReadOnlyDictionary<string, object>)new Dictionary<string, object>();
return (eventType, data);
})
.ToList();
}
/// <summary>Drive the session-4910a5a5 shape up to the terminal fire.</summary>
public void DriveToTerminalDeviceSetupAppsFailure()
{
Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (9 of 10 installed)""}
}");
Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
}");
Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
}
public void Dispose()
{
Tracker.Dispose();
Tmp.Dispose();
}
}
[Fact]
public void PostTerminalRetry_EmitsRetryDetected_Once()
{
using var f = new Fixture();
f.DriveToTerminalDeviceSetupAppsFailure();
Assert.Single(f.EspFailures);
// "Try again": Apps leaves the failed state well after the settle window.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
}");
var retry = Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_retry_detected");
Assert.Equal("DeviceSetup", (string)retry.Data["category"]);
Assert.Equal("Apps", (string)retry.Data["subcategory"]);
Assert.Equal("failed", (string)retry.Data["previousState"]);
Assert.Equal("inProgress", (string)retry.Data["newState"]);
Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", (string)retry.Data["failureType"]);
Assert.True(retry.Data.ContainsKey("minutesSinceFailure"));
// A later unrelated transition does not re-emit.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed)""}
}");
Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_retry_detected");
}
[Fact]
public void PostTerminalRecovery_EmitsRecovered_OnCategorySuccess()
{
using var f = new Fixture();
f.DriveToTerminalDeviceSetupAppsFailure();
// Retry → all apps installed → Windows resolves the category boolean.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
}");
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": true,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed)""}
}");
var recovered = Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_recovered");
Assert.Equal("DeviceSetup", (string)recovered.Data["category"]);
Assert.Equal("Apps", (string)recovered.Data["failedSubcategory"]);
Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", (string)recovered.Data["failureType"]);
Assert.True(recovered.Data.ContainsKey("minutesSinceFailure"));
// Duplicate category-success writes do not re-emit (fire-once).
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": true,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Security policies (1 of 1 applied)""},
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (10 of 10 installed, take 2)""}
}");
Assert.Single(f.CapturedEvents(), e => e.EventType == "esp_failure_recovered");
}
[Fact]
public void InSettleWindowRecovery_KeepsPostTerminalPairSilent()
{
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (0 of 1 installed)""}
}");
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
}");
// Recovery INSIDE the settle window — terminal fire is suppressed.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Apps (Working on it...)""}
}");
f.Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
Assert.Empty(f.EspFailures);
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": true,
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (1 of 1 installed)""}
}");
var events = f.CapturedEvents();
Assert.Contains(events, e => e.EventType == "esp_failure_settle_recovered");
Assert.DoesNotContain(events, e => e.EventType == "esp_failure_retry_detected");
Assert.DoesNotContain(events, e => e.EventType == "esp_failure_recovered");
}
[Fact]
public void AppsFailure_TerminalArgs_CarryLikelyCulprits_NeverStartedFirst()
{
var neverStarted = BuildApp("app-bg", "Teams Backgrounds v2",
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
downloadingSeen: false);
var startedButPending = BuildApp("app-slow", "Slow Installer",
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
downloadingSeen: true);
var installed = BuildApp("app-ok", "Falcon Sensor",
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Installed,
downloadingSeen: true);
using var f = new Fixture(() => new[] { startedButPending, neverStarted, installed });
f.DriveToTerminalDeviceSetupAppsFailure();
var fired = Assert.Single(f.EspFailures);
Assert.Equal(2, fired.LikelyCulpritApps.Count);
// Never-started app ranked first — it starved the ESP gate without any IME event.
Assert.Equal("Teams Backgrounds v2", fired.LikelyCulpritApps[0]);
Assert.Equal("Slow Installer", fired.LikelyCulpritApps[1]);
}
[Fact]
public void NonAppsFailure_TerminalArgs_CarryNoCulprits()
{
var neverStarted = BuildApp("app-bg", "Teams Backgrounds v2",
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown,
downloadingSeen: false);
using var f = new Fixture(() => new[] { neverStarted });
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""inProgress"",""subcategoryStatusText"":""Working""}
}");
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""SecurityPoliciesSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Failed""}
}");
f.Tracker.TriggerSettleTimerForTest("DeviceSetupCategory.Status");
var fired = Assert.Single(f.EspFailures);
Assert.Empty(fired.LikelyCulpritApps);
}
private static AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState BuildApp(
string id, string name,
AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState state,
bool downloadingSeen)
{
var pkg = new AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState(id, 0);
pkg.UpdateName(name);
pkg.UpdateIntent(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppIntent.Install);
pkg.UpdateTargeted(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppTargeted.Device);
// DownloadingOrInstallingSeen is derived inside UpdateState (Downloading..Installing
// range) — drive it through a Downloading transition instead of setting it directly.
if (downloadingSeen)
pkg.UpdateState(AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Downloading);
if (state != AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Unknown
&& state != AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppInstallationState.Downloading)
pkg.UpdateState(state);
return pkg;
}
}
}
@@ -0,0 +1,465 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Session 5d735290 — pure detection logic of <see cref="EspPolicyProviderStallDetector"/>:
/// the CSP completion rule and the dwell/one-shot transition. No registry, no timers.
/// </summary>
public sealed class EspPolicyProviderStallLogicTests
{
private static readonly DateTime T0 = new DateTime(2026, 7, 23, 10, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Dwell = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
private static PolicyProviderState SetupApps(string name, int? tracking, string scope = "device") =>
new PolicyProviderState(name, scope, EspPolicyProviderProbe.KindSetupApps, tracking, null);
private static PolicyProviderState DevicePrep(string name, int? installState) =>
new PolicyProviderState(name, "device", EspPolicyProviderProbe.KindDevicePreparation, null, installState);
private static EspPolicyProviderSnapshot Snapshot(params PolicyProviderState[] providers) =>
new EspPolicyProviderSnapshot(providers);
// ------------------------------------------------- completion rule (CSP contract)
[Theory]
[InlineData(1, true)] // TrackingPoliciesCreated=true
[InlineData(0, false)] // explicit false (the CSP default)
[InlineData(null, false)] // value missing — the field case
[InlineData(2, false)] // anything else is not "true"
public void SetupApps_provider_complete_only_at_tracking_1(int? tracking, bool expected)
=> Assert.Equal(expected, EspPolicyProviderProbe.IsProviderComplete(
EspPolicyProviderProbe.KindSetupApps, tracking, null));
[Theory]
[InlineData(2, true)] // NotRequired
[InlineData(3, true)] // Completed
[InlineData(1, false)] // NotInstalled
[InlineData(4, false)] // Error
[InlineData(null, false)]
public void DevicePreparation_provider_complete_at_state_2_or_3(int? state, bool expected)
=> Assert.Equal(expected, EspPolicyProviderProbe.IsProviderComplete(
EspPolicyProviderProbe.KindDevicePreparation, null, state));
[Fact]
public void TryReadInt_tolerates_dword_string_and_bool_forms()
{
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(1));
Assert.Equal(3, EspPolicyProviderProbe.TryReadInt(3L));
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(" 1 "));
Assert.Equal(1, EspPolicyProviderProbe.TryReadInt(true));
Assert.Equal(0, EspPolicyProviderProbe.TryReadInt(false));
Assert.Null(EspPolicyProviderProbe.TryReadInt(null));
Assert.Null(EspPolicyProviderProbe.TryReadInt("not-a-number"));
Assert.Null(EspPolicyProviderProbe.TryReadInt(new byte[] { 1 }));
}
// ------------------------------------------------- dwell transition
[Fact]
public void Empty_or_missing_snapshot_clears_dwell_state_and_never_stalls()
{
var previous = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
var missing = EspPolicyProviderStallDetector.EvaluateStallTransition(
EspPolicyProviderSnapshot.Empty, previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
Assert.Empty(missing.UpdatedFirstSeenUtc);
Assert.Empty(missing.NewlyStalled);
Assert.Null(missing.UpdatedSidecarMissingSinceUtc); // arm 2 resets too
Assert.Null(missing.SidecarMissingStalledForMinutes);
var registeredButEmpty = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(), previous, new HashSet<string>(), T0, false, T0.AddHours(2), Dwell);
Assert.Empty(registeredButEmpty.UpdatedFirstSeenUtc);
Assert.Empty(registeredButEmpty.NewlyStalled);
Assert.Null(registeredButEmpty.UpdatedSidecarMissingSinceUtc);
}
[Fact]
public void Incomplete_provider_accrues_dwell_but_does_not_stall_before_threshold()
{
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: null));
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(T0, round1.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]);
Assert.Empty(round1.NewlyStalled);
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(14), Dwell);
Assert.Equal(T0, round2.UpdatedFirstSeenUtc["setupApps|device|ConfigMgr"]); // first-seen preserved
Assert.Empty(round2.NewlyStalled);
Assert.Null(round2.SidecarMissingStalledForMinutes); // 14 min < dwell on arm 2 as well
}
[Fact]
public void Provider_stalls_at_dwell_threshold_with_correct_minutes()
{
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: 0));
var seen = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(15), Dwell);
var stalled = Assert.Single(eval.NewlyStalled);
Assert.Equal("ConfigMgr", stalled.Provider.Name);
Assert.Equal(15.0, stalled.StalledForMinutes, precision: 3);
}
[Fact]
public void Completion_or_disappearance_resets_the_dwell_clock()
{
var key = "setupApps|device|ConfigMgr";
var seen = new Dictionary<string, DateTime> { [key] = T0 };
// Completes mid-dwell → dropped from the map.
var completed = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)), seen, new HashSet<string>(), null, false, T0.AddMinutes(10), Dwell);
Assert.Empty(completed.UpdatedFirstSeenUtc);
// Re-appears incomplete much later → dwell restarts from the new now, no stall yet.
var reappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 0)), completed.UpdatedFirstSeenUtc,
new HashSet<string>(), null, false, T0.AddHours(3), Dwell);
Assert.Equal(T0.AddHours(3), reappeared.UpdatedFirstSeenUtc[key]);
Assert.Empty(reappeared.NewlyStalled);
// Disappearance behaves the same as completion.
var disappeared = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("Sidecar", tracking: 1)), new Dictionary<string, DateTime> { [key] = T0 },
new HashSet<string>(), null, false, T0.AddMinutes(20), Dwell);
Assert.False(disappeared.UpdatedFirstSeenUtc.ContainsKey(key));
}
[Fact]
public void Already_fired_provider_is_never_reported_again()
{
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: null));
var seen = new Dictionary<string, DateTime> { ["setupApps|device|ConfigMgr"] = T0 };
var fired = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "setupApps|device|ConfigMgr" };
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, fired, null, false, T0.AddHours(5), Dwell);
Assert.Empty(eval.NewlyStalled);
Assert.True(eval.UpdatedFirstSeenUtc.ContainsKey("setupApps|device|ConfigMgr")); // still tracked
}
[Fact]
public void Mixed_kinds_and_scopes_are_tracked_under_distinct_keys()
{
var snapshot = Snapshot(
SetupApps("ConfigMgr", tracking: null),
SetupApps("ConfigMgr", tracking: null, scope: "user:S-1-5-21-1111"),
DevicePrep("ConfigMgr", installState: 1));
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(3, eval.UpdatedFirstSeenUtc.Count);
Assert.Contains("setupApps|device|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
Assert.Contains("setupApps|user:S-1-5-21-1111|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
Assert.Contains("devicePreparation|device|ConfigMgr", eval.UpdatedFirstSeenUtc.Keys);
}
[Fact]
public void SidecarRegistered_matches_setupApps_sidecar_only()
{
Assert.True(Snapshot(SetupApps("Sidecar", tracking: 0)).SidecarRegistered);
Assert.True(Snapshot(SetupApps("sidecar", tracking: 1, scope: "user:S-1-5-21-1")).SidecarRegistered);
Assert.False(Snapshot(SetupApps("ConfigMgr", tracking: 1)).SidecarRegistered);
Assert.False(Snapshot(DevicePrep("Sidecar", installState: 3)).SidecarRegistered); // wrong kind
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
}
// ------------------------------------------------- arm 2: sidecar-missing transition
[Fact]
public void Issue106_foreign_provider_complete_but_sidecar_missing_stalls_after_dwell()
{
// The actual field case: ConfigMgr with TrackingPoliciesCreated=1 — "complete" by the
// CSP value contract, arm 1 never fires — yet the ESP waits on the absent Sidecar.
var snapshot = Snapshot(SetupApps("ConfigMgr", tracking: 1));
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Empty(round1.NewlyStalled); // arm 1 stays silent...
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc); // ...but arm 2 starts its clock
Assert.Null(round1.SidecarMissingStalledForMinutes);
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(15), Dwell);
Assert.Empty(round2.NewlyStalled);
Assert.Equal(15.0, round2.SidecarMissingStalledForMinutes!.Value, precision: 3);
}
[Fact]
public void Sidecar_registering_resets_the_sidecar_missing_clock()
{
// ConfigMgr first (legitimate startup ordering) → clock runs...
var round1 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Equal(T0, round1.UpdatedSidecarMissingSinceUtc);
// ...Sidecar joins → condition clears, state resets.
var round2 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1), SetupApps("Sidecar", tracking: 0)),
round1.UpdatedFirstSeenUtc, new HashSet<string>(),
round1.UpdatedSidecarMissingSinceUtc, false, T0.AddMinutes(10), Dwell);
Assert.Null(round2.UpdatedSidecarMissingSinceUtc);
Assert.Null(round2.SidecarMissingStalledForMinutes);
// Sidecar gone again much later → clock restarts from the new now, no instant stall.
var round3 = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
round2.UpdatedFirstSeenUtc, new HashSet<string>(),
round2.UpdatedSidecarMissingSinceUtc, false, T0.AddHours(3), Dwell);
Assert.Equal(T0.AddHours(3), round3.UpdatedSidecarMissingSinceUtc);
Assert.Null(round3.SidecarMissingStalledForMinutes);
}
[Fact]
public void Sidecar_missing_one_shot_and_deviceprep_only_scoping()
{
// Already fired → never re-reported, but the clock keeps running.
var latched = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(SetupApps("ConfigMgr", tracking: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), T0, true, T0.AddHours(5), Dwell);
Assert.Null(latched.SidecarMissingStalledForMinutes);
Assert.Equal(T0, latched.UpdatedSidecarMissingSinceUtc);
// DevicePreparation providers alone don't constitute the Setup/Apps wait — arm 2 off.
var devicePrepOnly = EspPolicyProviderStallDetector.EvaluateStallTransition(
Snapshot(DevicePrep("ConfigMgr", installState: 1)),
new Dictionary<string, DateTime>(), new HashSet<string>(), null, false, T0, Dwell);
Assert.Null(devicePrepOnly.UpdatedSidecarMissingSinceUtc);
}
}
/// <summary>
/// Emission surface of the detector via the real <see cref="InformationalEventPost"/> rail.
/// Static probe override → serial collection (same contract as
/// <c>EspConfigDetectedTrackingFieldsTests</c>).
/// </summary>
[Collection("SerialThreading")]
public sealed class EspPolicyProviderStallDetectorEmissionTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
BuildDetector(StartupEventGate? gate = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var post = new InformationalEventPost(sink, clock);
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
return (sut, sink, clock);
}
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
trackingPoliciesCreated: null, installationState: null),
});
private static EspPolicyProviderSnapshot SidecarCompleteConfigMgrIncomplete() => new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null),
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
});
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> StalledEvents(FakeSignalIngressSink sink) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == "esp_policy_provider_stalled").ToList();
[Fact]
public void CustomerScenario_configmgr_without_sidecar_emits_one_warning_after_dwell()
{
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly());
var (sut, sink, clock) = BuildDetector();
sut.Tick(); // first sighting — dwell starts
Assert.Empty(StalledEvents(sink));
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick(); // threshold crossed → emit
var evt = Assert.Single(StalledEvents(sink));
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
Assert.Equal("Warning", evt.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", evt.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Equal(EspPolicyProviderStallDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
// Both arms cross in the same round (ConfigMgr incomplete AND Sidecar absent) →
// ONE event; sidecar-missing is the dominant reason.
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
Assert.Equal(false, data["sidecarRegistered"]);
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
Assert.Equal(EspPolicyProviderStallDetector.DwellMinutes, data["dwellMinutes"]);
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
var entry = Assert.Single(stalled);
Assert.Equal("ConfigMgr", entry["name"]);
Assert.Equal(EspPolicyProviderProbe.KindSetupApps, entry["kind"]);
Assert.Equal(15.0, entry["stalledForMinutes"]);
}
[Fact]
public void Issue106_configmgr_complete_without_sidecar_still_emits_sidecar_missing()
{
// Regression test for the customer's follow-up on issue #106: TrackingPoliciesCreated=1
// WAS present under ConfigMgr — the provider looks "complete", arm 1 never fires, yet
// the ESP hangs because Sidecar is absent. Arm 2 must catch exactly this.
var configMgrComplete = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
trackingPoliciesCreated: 1, installationState: null),
});
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => configMgrComplete);
var (sut, sink, clock) = BuildDetector();
sut.Tick();
Assert.Empty(StalledEvents(sink));
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(EspPolicyProviderStallDetector.ReasonSidecarMissing, data["reason"]);
Assert.Equal(false, data["sidecarRegistered"]);
Assert.Equal(15.0, data["sidecarMissingForMinutes"]);
Assert.Empty(Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]));
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
Assert.Equal(true, Assert.Single(providers)["complete"]); // the misleading "complete" is visible
}
[Fact]
public void Sidecar_complete_configmgr_incomplete_reports_only_configmgr_with_sidecar_flag()
{
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => SidecarCompleteConfigMgrIncomplete());
var (sut, sink, clock) = BuildDetector();
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(EspPolicyProviderStallDetector.ReasonProviderIncomplete, data["reason"]); // arm 2 off — Sidecar is there
Assert.Equal(true, data["sidecarRegistered"]);
Assert.False(data.ContainsKey("sidecarMissingForMinutes"));
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
Assert.Equal("ConfigMgr", Assert.Single(stalled)["name"]);
var providers = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object?>>>(data["providers"]);
Assert.Equal(2, providers.Count); // full table always attached
}
[Fact]
public void Latch_prevents_second_emission_for_the_same_provider()
{
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly());
var (sut, sink, clock) = BuildDetector();
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(30));
sut.Tick();
sut.Tick();
Assert.Single(StalledEvents(sink));
}
[Fact]
public void Startup_gate_suppresses_same_stall_across_restart_but_reports_new_provider_set()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
// Run 1: fires and persists the fingerprint.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly()))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
Assert.Single(StalledEvents(sink));
}
// Run 2 (simulated restart — fresh detector, gate reloaded from the same state dir):
// identical stall re-accrues dwell but lands on the same fingerprint → suppressed.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => ConfigMgrOnly()))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
// Run 3: a SECOND provider joins the stall → fingerprint changes → reported.
var widened = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
});
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => widened))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
sut.Tick();
clock.Advance(TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes));
sut.Tick();
Assert.Single(StalledEvents(sink));
}
}
[Fact]
public void Healthy_or_absent_tracking_never_emits()
{
var healthy = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null),
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, 3),
});
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => healthy))
{
var (sut, sink, clock) = BuildDetector();
sut.Tick();
clock.Advance(TimeSpan.FromHours(4));
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => EspPolicyProviderSnapshot.Empty))
{
var (sut, sink, clock) = BuildDetector();
sut.Tick();
clock.Advance(TimeSpan.FromHours(4));
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
}
}
}
@@ -0,0 +1,336 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// MdmRebootPolicyTracker — aggregates DM-Enterprise EventID 2800 bursts into ONE neutral
/// <c>mdm_policy_reboot_required</c> event (session b2e890c1 rework: per-record events were
/// spam, and the raw event must not CLAIM a reboot — ANALYZE-ESP-005 does, gated on an
/// observed system_reboot_detected). Drives the primitive ProcessEvent seam + explicit
/// FlushPending (debounce 0 = manual-flush mode; no timers, no SerialThreading needed).
/// The registry probe is always overridden — tests never touch the live registry.
/// </summary>
public sealed class MdmRebootPolicyTrackerTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 7, 20, 16, 20, 57, DateTimeKind.Utc);
private const string UriSvchost = "./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation";
private const string UriSystemGuard = "./Device/Vendor/MSFT/Policy/Config/DeviceGuard/ConfigureSystemGuardLaunch";
// Verified real 2800 description shape (session b2e890c1, 2026-07-20).
private const string RealDescription =
"The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).";
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink;
private readonly MdmRebootPolicyTracker _tracker;
private long _nextRecordId = 1;
public MdmRebootPolicyTrackerTests()
{
_sink = new FakeSignalIngressSink();
_tracker = MakeTracker(stateDirectory: null);
}
public void Dispose() => _tmp.Dispose();
private MdmRebootPolicyTracker MakeTracker(string? stateDirectory)
{
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var tracker = new MdmRebootPolicyTracker(
sessionId: "sess-mdm",
tenantId: "tenant-mdm",
post: post,
logger: logger,
backfillEnabled: false,
stateDirectory: stateDirectory,
debounceMilliseconds: 0); // manual-flush mode — tests call FlushPending explicitly
tracker.RebootRequiredFlagProbe = () => null; // registry untouched by default
return tracker;
}
private void Buffer(string? rebootUri, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null, string? description = null)
{
_tracker.ProcessEvent(
eventId: MdmRebootPolicyTracker.EventId_PolicyRebootRequired,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
rebootUri: rebootUri,
formattedDescription: description,
isBackfill: isBackfill);
}
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Emitted() =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == Constants.EventTypes.MdmPolicyRebootRequired).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
[Fact]
public void Burst_FlushesAsOneAggregatedEvent_InfoSeverity_ImmediateUpload()
{
Buffer(UriSvchost, timeCreatedUtc: At);
Buffer(UriSystemGuard, timeCreatedUtc: At.AddMilliseconds(120));
_tracker.FlushPending();
var s = Assert.Single(Emitted());
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal(2, data["uriCount"]);
Assert.Equal(2, data["recordCount"]);
Assert.Equal(UriSvchost, data["firstRebootUri"]);
var uris = Assert.IsType<List<string>>(data["rebootUris"]);
Assert.Equal(2, uris.Count);
Assert.Contains(UriSvchost, uris);
Assert.Contains(UriSystemGuard, uris);
}
[Fact]
public void Message_IsNeutralObservation_NoRebootClaim()
{
// Session b2e890c1: flags raised during AccountSetup have no effect — the raw event
// must state the observation only, never a future consequence.
Buffer(UriSvchost);
_tracker.FlushPending();
var s = Assert.Single(Emitted());
var message = s.Payload![SignalPayloadKeys.Message];
Assert.Contains("flagged", message);
Assert.DoesNotContain("will restart", message);
Assert.DoesNotContain("second sign-in", message);
}
[Fact]
public void FlushPending_Empty_EmitsNothing()
{
_tracker.FlushPending();
Assert.Empty(Emitted());
}
[Fact]
public void AggregateTimestamp_IsEarliestRecordTime()
{
var early = At.AddMinutes(-30);
Buffer(UriSystemGuard, timeCreatedUtc: At, isBackfill: true);
Buffer(UriSvchost, timeCreatedUtc: early, isBackfill: true);
_tracker.FlushPending();
var s = Assert.Single(Emitted());
Assert.Equal(early, s.OccurredAtUtc);
Assert.Equal(true, Data(s)["backfilled"]);
// firstRebootUri follows record-time order, not buffer order.
Assert.Equal(UriSvchost, Data(s)["firstRebootUri"]);
}
[Fact]
public void Backfilled_False_WhenAnyRecordIsLive()
{
Buffer(UriSvchost, isBackfill: true);
Buffer(UriSystemGuard, isBackfill: false);
_tracker.FlushPending();
Assert.Equal(false, Data(Assert.Single(Emitted()))["backfilled"]);
}
[Fact]
public void DuplicateUris_AreDistinctInAggregate()
{
Buffer(UriSvchost);
Buffer(UriSvchost);
_tracker.FlushPending();
var data = Data(Assert.Single(Emitted()));
Assert.Equal(1, data["uriCount"]);
Assert.Equal(2, data["recordCount"]);
}
[Fact]
public void UnparsedRecords_CountedWithSampleDescription_StillEmitted()
{
Buffer(rebootUri: null, description: "Some unexpected 2800 wording");
_tracker.FlushPending();
var s = Assert.Single(Emitted());
var data = Data(s);
Assert.False(data.ContainsKey("rebootUris"));
Assert.False(data.ContainsKey("firstRebootUri"));
Assert.Equal(0, data["uriCount"]);
Assert.Equal(1, data["unparsedCount"]);
Assert.Equal("Some unexpected 2800 wording", data["sampleDescription"]);
}
[Fact]
public void SameRecordId_BufferedTwice_CountsOnce()
{
Buffer(UriSvchost, recordId: 42);
Buffer(UriSvchost, recordId: 42);
_tracker.FlushPending();
Assert.Equal(1, Data(Assert.Single(Emitted()))["recordCount"]);
}
[Fact]
public void RecordAfterFlush_GoesIntoNextAggregate()
{
Buffer(UriSvchost, recordId: 10);
_tracker.FlushPending();
Buffer(UriSystemGuard, recordId: 11);
_tracker.FlushPending();
Assert.Equal(2, Emitted().Count);
}
[Fact]
public void BackfillRecordWithLowerRecordId_AfterLiveRecord_IsStillBuffered()
{
// Live watcher is armed BEFORE the backfill scan — an older, never-seen backfill
// record must not be suppressed by a high-water mark.
Buffer(UriSvchost, recordId: 100);
Buffer(UriSystemGuard, recordId: 50, isBackfill: true);
_tracker.FlushPending();
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
}
[Fact]
public void NegativeRecordId_IsNeverDeduped()
{
Buffer(UriSvchost, recordId: -1);
Buffer(UriSvchost, recordId: -1);
_tracker.FlushPending();
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
}
[Fact]
public void Watermark_PersistedAtFlush_SkipsReReadAcrossInstances()
{
// The coalesced reboot restarts the agent; the post-reboot backfill re-reads the same
// records — a FLUSHED record must be skipped by the next instance.
var first = MakeTracker(stateDirectory: _tmp.Path);
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
first.FlushPending();
var second = MakeTracker(stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
second.FlushPending();
Assert.Single(Emitted());
}
[Fact]
public void Watermark_NotPersisted_WhenBufferedButNeverFlushed()
{
// Process killed by the very reboot the records announce: nothing was flushed, so the
// next instance must re-read and emit the records.
var first = MakeTracker(stateDirectory: _tmp.Path);
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
// no FlushPending — simulated reboot kill
var second = MakeTracker(stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
second.FlushPending();
var s = Assert.Single(Emitted());
Assert.Equal(true, Data(s)["backfilled"]);
}
[Fact]
public void RebootRequiredFlagProbe_True_AddsField_Null_OmitsIt()
{
_tracker.RebootRequiredFlagProbe = () => true;
Buffer(UriSvchost, recordId: 1);
_tracker.FlushPending();
_tracker.RebootRequiredFlagProbe = () => null;
Buffer(UriSystemGuard, recordId: 2);
_tracker.FlushPending();
var emitted = Emitted();
Assert.Equal(2, emitted.Count);
Assert.Equal(true, Data(emitted[0])["omadmRebootRequiredFlag"]);
Assert.False(Data(emitted[1]).ContainsKey("omadmRebootRequiredFlag"));
}
[Fact]
public void RebootRequiredFlagProbe_Throwing_IsFailSoft()
{
_tracker.RebootRequiredFlagProbe = () => throw new InvalidOperationException("boom");
Buffer(UriSvchost);
_tracker.FlushPending();
Assert.False(Data(Assert.Single(Emitted())).ContainsKey("omadmRebootRequiredFlag"));
}
[Fact]
public void BuildXPath_Targets2800()
{
Assert.Equal("*[System[(EventID=2800)]]", MdmRebootPolicyTracker.BuildXPath());
}
// ------------------------------------------------------------------ URI extraction
[Fact]
public void ExtractRebootUri_FromVerifiedRealDescription()
{
// Real 2800 text wraps the URI in parentheses and ends with a period — both must be
// excluded from the extracted URI.
var uri = MdmRebootPolicyTracker.ExtractRebootUri(null, RealDescription);
Assert.Equal(UriSvchost, uri);
}
[Fact]
public void ExtractRebootUri_PrefersEventDataValue_OverDescription()
{
var eventData = new Dictionary<string, string> { ["Data0"] = " " + UriSystemGuard + " " };
var uri = MdmRebootPolicyTracker.ExtractRebootUri(eventData, RealDescription);
Assert.Equal(UriSystemGuard, uri);
}
[Fact]
public void ExtractRebootUri_ReturnsNull_WhenNothingUriLike()
{
Assert.Null(MdmRebootPolicyTracker.ExtractRebootUri(
new Dictionary<string, string> { ["Data0"] = "plain" }, "no uri here"));
Assert.Null(MdmRebootPolicyTracker.ExtractRebootUri(null, null));
}
[Fact]
public void ParseEventData_CollectsNamedAndPositionalData()
{
const string xml =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
"<System><EventID>2800</EventID></System>" +
"<EventData><Data Name='Uri'>./Device/Vendor/MSFT/X</Data><Data>positional</Data></EventData>" +
"</Event>";
var parsed = MdmRebootPolicyTracker.ParseEventData(xml);
Assert.Equal("./Device/Vendor/MSFT/X", parsed["Uri"]);
Assert.Equal("positional", parsed["Data0"]);
}
}
}
@@ -112,6 +112,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.Equal(false, data["backfilled"]);
}
[Fact]
public void ErrorBackfillSummary_EmitsInfoLogEvent_AnnouncingTheReplayBlock()
{
_tracker.EmitErrorBackfillSummary(replayedCount: 6, droppedOverCap: 0);
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
Assert.Equal("modern_deployment_log", posted.Payload![SignalPayloadKeys.EventType]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(posted.TypedPayload);
Assert.Equal(true, data["backfillSummary"]);
Assert.Equal(6, data["replayedCount"]);
Assert.Equal(0, data["droppedOverCap"]);
Assert.Equal(240, data["lookbackMinutes"]);
var message = (string)posted.Payload[SignalPayloadKeys.Message];
Assert.Contains("Replaying 6 error event(s)", message);
Assert.Contains("before the agent started", message);
Assert.DoesNotContain("cap", message);
}
[Fact]
public void ErrorBackfillSummary_MentionsDroppedRecords_WhenCapWasHit()
{
_tracker.EmitErrorBackfillSummary(replayedCount: 25, droppedOverCap: 7);
var posted = _sink.Posted.Single(p => p.Kind == DecisionSignalKind.InformationalEvent);
var message = (string)posted.Payload![SignalPayloadKeys.Message];
Assert.Contains("7 older record(s) beyond the 25-record cap", message);
}
[Fact]
public void BackfilledHarmlessId_StillRunsThroughRollupSuppression()
{
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
public List<EspFailureDetectedEventArgs> EspFailures { get; } = new List<EspFailureDetectedEventArgs>();
public ProvisioningStatusTracker Tracker { get; }
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>> packageStatesProbe = null)
public Fixture(Func<IReadOnlyList<AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
{
var clock = new VirtualClock(Fixed);
var post = new InformationalEventPost(Sink, clock);
@@ -179,7 +179,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
}");
var captured = f.CapturedEvents();
Assert.Single(captured.Where(e => e.EventType == "esp_failure_settle_started"));
Assert.Single(captured, e => e.EventType == "esp_failure_settle_started");
}
// =====================================================================
@@ -214,8 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
// No terminal fire — the failure was retracted.
Assert.Empty(f.EspFailures);
var recovered = Assert.Single(f.CapturedEvents()
.Where(e => e.EventType == "esp_failure_settle_recovered"));
var recovered = Assert.Single(
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
Assert.Equal("AccountSetup", (string)recovered.Data["category"]);
Assert.Equal("Apps", (string)recovered.Data["failedSubcategory"]);
Assert.Equal("inProgress", (string)recovered.Data["observedState"]);
@@ -280,7 +280,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
f.Tracker.TriggerSettleTimerForTest("AccountSetupCategory.Status");
Assert.Single(f.EspFailures);
Assert.Empty(f.CapturedEvents().Where(e => e.EventType == "esp_failure_settle_recovered"));
Assert.DoesNotContain(f.CapturedEvents(), e => e.EventType == "esp_failure_settle_recovered");
}
// =====================================================================
@@ -326,8 +326,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
""AppsSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Apps (Error)""}
}");
var settleStarted = Assert.Single(f.CapturedEvents()
.Where(e => e.EventType == "esp_failure_settle_started"));
var settleStarted = Assert.Single(
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
Assert.Equal(1, (int)settleStarted.Data["trackedAppsNotCompletedCount"]);
var apps = (List<Dictionary<string, object>>)settleStarted.Data["trackedAppsNotCompleted"];
var app = Assert.Single(apps);
@@ -354,8 +354,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
""SecurityPoliciesSubcategory"": {""subcategoryState"":""failed"",""subcategoryStatusText"":""Failed""}
}");
var settleStarted = Assert.Single(f.CapturedEvents()
.Where(e => e.EventType == "esp_failure_settle_started"));
var settleStarted = Assert.Single(
f.CapturedEvents(), e => e.EventType == "esp_failure_settle_started");
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompleted"));
Assert.False(settleStarted.Data.ContainsKey("trackedAppsNotCompletedCount"));
}
@@ -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));
}
}
}
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
{
/// <summary>
/// Pins the anti-flood semantics of <see cref="EmergencyReporter.TrySendAsync"/> after the
/// delivery-hardening change (2026-07-23): the once-per-session dedup and the per-session
/// budget count DELIVERED reports. The previous mark-before-send permanently suppressed an
/// error key after its first transport failure — fatal for single-shot callers like the 48h
/// emergency break, whose process exits right after the attempt.
/// </summary>
public class EmergencyReporterTests
{
private sealed class FakeApiClient : BackendApiClient
{
public readonly List<TimeSpan?> Timeouts = new List<TimeSpan?>();
public readonly Queue<bool> Results = new Queue<bool>();
public FakeApiClient() : base(
httpClient: new HttpClient(),
baseUrl: "http://localhost",
manufacturer: string.Empty,
model: string.Empty,
serialNumber: string.Empty,
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "0.0.0",
logger: null)
{
}
public int Calls => Timeouts.Count;
public override Task<bool> ReportAgentErrorAsync(AgentErrorReport report, TimeSpan? timeout = null)
{
Timeouts.Add(timeout);
return Task.FromResult(Results.Count > 0 && Results.Dequeue());
}
}
private static EmergencyReporter Reporter(FakeApiClient client)
=> new EmergencyReporter(client, "session", "tenant", "0.0.0", logger: null);
[Fact]
public async Task Failed_send_rolls_back_reservation_so_a_later_call_retries()
{
var client = new FakeApiClient();
client.Results.Enqueue(false); // first call fails
client.Results.Enqueue(true); // second call succeeds
var reporter = Reporter(client);
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
// Old behaviour: second call suppressed as "already reported" ⇒ 1 call.
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Delivered_send_is_deduplicated_per_key()
{
var client = new FakeApiClient();
client.Results.Enqueue(true);
var reporter = Reporter(client);
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
Assert.Equal(1, client.Calls);
}
[Fact]
public async Task Retries_stay_inside_one_reservation_and_stop_on_success()
{
var client = new FakeApiClient();
client.Results.Enqueue(false);
client.Results.Enqueue(true); // second attempt delivers — third must not fire
var reporter = Reporter(client);
await reporter.TrySendAsync(
AgentErrorType.SessionAgeEmergencyBreak, "m",
attempts: 3, perAttemptTimeout: TimeSpan.FromMilliseconds(50), retryDelay: TimeSpan.Zero);
Assert.Equal(2, client.Calls);
// Delivered ⇒ dedup holds for the session.
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Per_attempt_timeout_is_forwarded_to_the_api_client()
{
var client = new FakeApiClient();
client.Results.Enqueue(true);
var reporter = Reporter(client);
var timeout = TimeSpan.FromSeconds(15);
await reporter.TrySendAsync(
AgentErrorType.SessionAgeEmergencyBreak, "m",
attempts: 1, perAttemptTimeout: timeout);
Assert.Equal(timeout, Assert.Single(client.Timeouts));
}
[Fact]
public async Task All_attempts_failing_rolls_back_after_exhausting_attempts()
{
var client = new FakeApiClient(); // queue empty ⇒ every attempt fails
var reporter = Reporter(client);
await reporter.TrySendAsync(
AgentErrorType.SessionAgeEmergencyBreak, "m",
attempts: 3, retryDelay: TimeSpan.Zero);
Assert.Equal(3, client.Calls);
// Rolled back ⇒ a later call may try again (budget + dedup + cooldown restored).
client.Results.Enqueue(true);
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
Assert.Equal(4, client.Calls);
}
[Fact]
public async Task Different_keys_do_not_suppress_each_other_after_rollback()
{
var client = new FakeApiClient();
client.Results.Enqueue(false); // break report fails → rollback (incl. cooldown restore)
client.Results.Enqueue(true); // unrelated report must still go out
var reporter = Reporter(client);
await reporter.TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m");
await reporter.TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
Assert.Equal(2, client.Calls);
}
}
}
@@ -0,0 +1,212 @@
#nullable enable
using System.Collections.Generic;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// Pins the pure parts of the UEFI Secure Boot certificate probe: the ASCII substring
/// search over raw signature-list bytes and the <c>secureboot_status</c> payload shaping
/// (in particular the one-sided <c>uefiCa2023FirmwareConfirmed</c> marker whose ABSENCE
/// is load-bearing for ANALYZE-SEC-001's not_exists precondition). The actual firmware
/// read is only smoke-tested: test machines may be VMs, non-elevated, or legacy BIOS,
/// so only the fail-soft contract is asserted.
/// </summary>
public sealed class UefiSecureBootCertReaderTests
{
// --- ContainsAscii ---
[Fact]
public void ContainsAscii_finds_needle_embedded_in_binary_noise()
{
var bytes = Concat(new byte[] { 0x00, 0xA1, 0x30, 0x82 },
Encoding.ASCII.GetBytes("Windows UEFI CA 2023"),
new byte[] { 0x00, 0xFF });
Assert.True(UefiSecureBootCertReader.ContainsAscii(bytes, "Windows UEFI CA 2023"));
}
[Fact]
public void ContainsAscii_finds_needle_at_the_very_end()
{
var bytes = Concat(new byte[] { 0x01, 0x02 }, Encoding.ASCII.GetBytes("KEK 2K CA 2023"));
Assert.True(UefiSecureBootCertReader.ContainsAscii(bytes, "KEK 2K CA 2023"));
}
[Fact]
public void ContainsAscii_rejects_partial_overlap_and_absent_needle()
{
var bytes = Encoding.ASCII.GetBytes("Windows UEFI CA 2011 something else");
Assert.False(UefiSecureBootCertReader.ContainsAscii(bytes, "Windows UEFI CA 2023"));
// Repeated prefix bytes must not confuse the scan.
var tricky = Encoding.ASCII.GetBytes("ababac");
Assert.True(UefiSecureBootCertReader.ContainsAscii(tricky, "abac"));
}
[Fact]
public void ContainsAscii_handles_null_empty_and_oversized_needle()
{
Assert.False(UefiSecureBootCertReader.ContainsAscii(null, "x"));
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[0], "x"));
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[] { 1, 2 }, ""));
Assert.False(UefiSecureBootCertReader.ContainsAscii(new byte[] { 1, 2 }, "abc"));
}
// --- Payload shaping ---
[Fact]
public void Marker_emitted_only_when_db_ok_and_ca2023_present()
{
var data = new Dictionary<string, object>();
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
{
DbStatus = UefiSecureBootCertReader.StatusOk,
KekStatus = UefiSecureBootCertReader.StatusOk,
DbHasWindowsUefiCa2023 = true,
KekHasMicrosoftKek2kCa2023 = true
});
Assert.Equal(true, data["uefiCa2023FirmwareConfirmed"]);
Assert.Equal("ok", data["uefiFirmwareReadStatus"]);
Assert.Equal(true, data["uefiDbHasWindowsUefiCa2023"]);
Assert.Equal(true, data["uefiKekHasMicrosoftKek2kCa2023"]);
}
[Fact]
public void Marker_absent_when_ca2023_missing_even_though_read_succeeded()
{
var data = new Dictionary<string, object>();
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
{
DbStatus = UefiSecureBootCertReader.StatusOk,
KekStatus = UefiSecureBootCertReader.StatusOk,
DbHasWindowsUefiCa2023 = false,
DbHasWindowsProductionPca2011 = true
});
// The marker must be OMITTED, not false — not_exists fails on any present value.
Assert.False(data.ContainsKey("uefiCa2023FirmwareConfirmed"));
Assert.Equal(false, data["uefiDbHasWindowsUefiCa2023"]);
Assert.Equal(true, data["uefiDbHasWindowsProductionPca2011"]);
}
[Fact]
public void Failed_db_read_emits_status_but_no_cert_booleans_and_no_marker()
{
var data = new Dictionary<string, object>();
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
{
DbStatus = UefiSecureBootCertReader.StatusPrivilegeDenied,
KekStatus = UefiSecureBootCertReader.StatusPrivilegeDenied
});
Assert.Equal(UefiSecureBootCertReader.StatusPrivilegeDenied, data["uefiFirmwareReadStatus"]);
Assert.False(data.ContainsKey("uefiDbHasWindowsUefiCa2023"));
Assert.False(data.ContainsKey("uefiKekHasMicrosoftKek2kCa2023"));
Assert.False(data.ContainsKey("uefiCa2023FirmwareConfirmed"));
}
[Fact]
public void Db_ok_but_kek_failed_keeps_db_booleans_and_reports_kek_status()
{
var data = new Dictionary<string, object>();
DeviceInfoCollector.AppendFirmwareCertFields(data, new UefiSecureBootCertSnapshot
{
DbStatus = UefiSecureBootCertReader.StatusOk,
KekStatus = UefiSecureBootCertReader.StatusVariableNotFound,
DbHasWindowsUefiCa2023 = true
});
Assert.Equal(UefiSecureBootCertReader.StatusVariableNotFound, data["uefiFirmwareReadStatus"]);
Assert.Equal(true, data["uefiDbHasWindowsUefiCa2023"]);
Assert.Equal(true, data["uefiCa2023FirmwareConfirmed"]);
Assert.False(data.ContainsKey("uefiKekHasMicrosoftKek2kCa2023"));
}
[Fact]
public void Null_snapshot_leaves_payload_untouched()
{
var data = new Dictionary<string, object> { { "uefiSecureBootEnabled", true } };
DeviceInfoCollector.AppendFirmwareCertFields(data, null);
Assert.Single(data);
}
[Fact]
public void DescribeFirmwareVerdict_covers_all_shapes()
{
Assert.Equal("unavailable", DeviceInfoCollector.DescribeFirmwareVerdict(null));
Assert.Equal(UefiSecureBootCertReader.StatusNotUefi, DeviceInfoCollector.DescribeFirmwareVerdict(
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusNotUefi }));
Assert.Equal("CA2023 confirmed", DeviceInfoCollector.DescribeFirmwareVerdict(
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusOk, DbHasWindowsUefiCa2023 = true }));
Assert.Equal("CA2023 missing", DeviceInfoCollector.DescribeFirmwareVerdict(
new UefiSecureBootCertSnapshot { DbStatus = UefiSecureBootCertReader.StatusOk, DbHasWindowsUefiCa2023 = false }));
}
// --- Reader smoke (real firmware; machine state unknown) ---
[Fact]
public void Read_never_throws_and_reports_a_status_for_both_variables()
{
var snapshot = UefiSecureBootCertReader.Read();
Assert.NotNull(snapshot);
Assert.False(string.IsNullOrEmpty(snapshot.DbStatus));
Assert.False(string.IsNullOrEmpty(snapshot.KekStatus));
}
/// <summary>
/// SeSystemEnvironmentPrivilege is adjusted on the PROCESS token, so the enable →
/// read → restore sequence is a process-wide side effect. Unserialized, a second
/// reader observes "already enabled", skips its own restore, and then loses the
/// privilege mid-read when the first reader disposes — surfacing as a privilege_denied
/// that disagrees with the other readers. The machine's actual privilege state does
/// not matter here: whatever it is, every reader must report the SAME status.
/// </summary>
[Fact]
public void Read_is_serialized_across_concurrent_callers()
{
const int readers = 8;
var snapshots = new UefiSecureBootCertSnapshot[readers];
var failures = new System.Exception[readers];
var threads = new System.Threading.Thread[readers];
for (var i = 0; i < readers; i++)
{
var index = i;
threads[i] = new System.Threading.Thread(() =>
{
try { snapshots[index] = UefiSecureBootCertReader.Read(); }
catch (System.Exception ex) { failures[index] = ex; }
});
threads[i].Start();
}
foreach (var thread in threads) thread.Join();
Assert.All(failures, Assert.Null);
Assert.All(snapshots, s => Assert.False(string.IsNullOrEmpty(s.DbStatus)));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].DbStatus, s.DbStatus));
Assert.All(snapshots, s => Assert.Equal(snapshots[0].KekStatus, s.KekStatus));
}
private static byte[] Concat(params byte[][] parts)
{
var total = 0;
foreach (var part in parts) total += part.Length;
var result = new byte[total];
var offset = 0;
foreach (var part in parts)
{
System.Array.Copy(part, 0, result, offset, part.Length);
offset += part.Length;
}
return result;
}
}
}
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
private readonly FakeQuarantineSink _quarantine = new FakeQuarantineSink();
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
private readonly DecisionEngine _engine = new DecisionEngine();
private DecisionStepProcessor _sut;
private DecisionStepProcessor? _sut;
public DecisionStepProcessorParkedTripwireTests()
{
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
return (step, signal);
}
private FakeSignalIngressSink.PostedSignal FindTripwirePost()
private FakeSignalIngressSink.PostedSignal? FindTripwirePost()
{
foreach (var posted in _sink.Posted)
{
@@ -121,6 +121,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
return null;
}
/// <summary>The tripwire post, asserted present — for the assertions that only run
/// after <c>WaitForTripwire()</c> has already confirmed one was emitted.</summary>
private FakeSignalIngressSink.PostedSignal RequireTripwirePost()
{
var post = FindTripwirePost();
Assert.NotNull(post);
return post!;
}
private int CountTripwirePosts()
{
var count = 0;
@@ -165,9 +174,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Thread.Sleep(SuppressWait);
Assert.Equal(1, CountTripwirePosts());
var post = FindTripwirePost();
var post = RequireTripwirePost();
Assert.Equal(DecisionSignalKind.InformationalEvent, post.Kind);
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload["stage"]);
Assert.Equal(nameof(SessionStage.EspAccountSetup), post.Payload!["stage"]);
Assert.Equal("Warning", post.Payload[SignalPayloadKeys.Severity]);
Assert.Equal("true", post.Payload[SignalPayloadKeys.ImmediateUpload]);
Assert.Contains("esp_final_exit", post.Payload["signalsSeen"]);
@@ -222,7 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
sut.ApplyStep(step, signal);
Assert.True(WaitForTripwire());
Assert.Equal(DeadlineNames.ClassifierTick, FindTripwirePost().Payload["armedDeadlines"]);
Assert.Equal(DeadlineNames.ClassifierTick, RequireTripwirePost().Payload!["armedDeadlines"]);
}
[Fact]
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
sut.ApplyStep(step, signal);
Assert.True(WaitForTripwire());
Assert.Equal(nameof(SessionStage.AwaitingDesktop), FindTripwirePost().Payload["stage"]);
Assert.Equal(nameof(SessionStage.AwaitingDesktop), RequireTripwirePost().Payload!["stage"]);
}
// ===================================================================== Dwell semantics
@@ -37,5 +37,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.EspPhaseChanged, null));
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.InformationalEvent, Phase("FinalizingSetup")));
}
// Audit Q2 — the payload shape the adapter's apps sub-phase declaration produces on the
// ingress rail (InformationalEventPost: eventType + declared phase enum name).
private static IReadOnlyDictionary<string, string> SubPhaseDeclaration(string declaredPhase) =>
new Dictionary<string, string>
{
{ SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition },
{ AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter.PhaseParamKey, declaredPhase },
};
[Fact]
public void Apps_subphase_declarations_classify_as_esp_config_refresh_triggers()
{
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsDevice,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsDevice")));
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsUser,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsUser")));
// Non-apps phase declarations (e.g. the reducer's Finalizing entry) never classify.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("FinalizingSetup")));
// A phase_transition without a declared phase never classifies.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
new Dictionary<string, string> { { SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition } }));
}
[Fact]
public void AccountSetup_phase_classifies_as_the_user_setup_refresh_trigger()
{
Assert.Equal(DeviceInfoHost.EspConfigTriggerAccountSetup,
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("AccountSetup")));
// DeviceSetup is the enrollment-start trigger, not an ESP-config refresh.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("DeviceSetup")));
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, null));
// Unrelated informational events never classify.
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
new Dictionary<string, string> { { SignalPayloadKeys.EventType, "esp_config_detected" } }));
}
}
}
@@ -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));

Some files were not shown because too many files have changed in this diff Show More