Compare commits

...

112 Commits

Author SHA1 Message Date
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
644 changed files with 29422 additions and 7221 deletions
+2 -2
View File
@@ -194,8 +194,8 @@ jobs:
# Dev variant = literal-string substitution of the two URL/manifest defaults.
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
$stableUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip"'
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
$stableMan = '$VersionJsonName = "version.json"'
$devMan = '$VersionJsonName = "version-dev.json"'
+12
View File
@@ -69,9 +69,21 @@ jobs:
- run: npm ci
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
# v16); typegen generates them so tsc sees the same types as a build.
- name: Generate Next.js types
run: npx next typegen
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
- name: Lint
run: npm run lint -- --max-warnings 99
- name: Tests
run: npm test
+2 -2
View File
@@ -57,7 +57,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -68,6 +68,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.3
with:
category: /language:${{ matrix.language }}
+10 -1
View File
@@ -39,6 +39,10 @@ jobs:
uses: Azure/static-web-apps-deploy@v1
env:
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
# Dual app-reg window: previous app registration (unset = single-app behaviour)
# and the default app for browsers without a stored selection (legacy|primary).
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
@@ -52,7 +56,12 @@ jobs:
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "src/Web/autopilot-monitor-web" # App source code path
api_location: "" # Api source code path - optional
output_location: "" # Built app content directory - optional
# Static export (next.config output:'export'): Oryx runs `npm run build`
# and uploads the exported out/ directory as plain static files — no
# managed SSR runtime is provisioned anymore. (No is_static_export
# input on this action version; Oryx detects the export from
# next.config + output_location.)
output_location: "out" # Built app content directory - optional
###### End of Repository/Build Configurations ######
close_pull_request_job:
+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.'
+6
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/
@@ -148,3 +151,6 @@ plans/REFACTOR_AGENT_V2.md
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
.playwright-mcp/
+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"]
}
}
}
+3 -3
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%" />
+13
View File
@@ -119,6 +119,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
+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`
+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`
+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
@@ -0,0 +1,75 @@
---
type: concept
title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-02
---
# Schema
## Activation
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
30 s negative cache).
Two activation routes, one shared implementation
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
Typical latency: ~1 minute after signup.
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
arbitrates concurrent activations; exactly one caller runs the side effects
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
## The welcome-mail race
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
same table) — the only reliable address: it is typed by the user on the activation-pending
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
`ContactEmail` is merely seeded from the notification email.
That makes activation and address entry concurrent writers. With auto-approve, activation
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
approval path found no address and the mail was silently skipped).
**Arbitration — write-then-read on both sides plus a send-once marker:**
* The approval path writes the `approved` row, then reads the address.
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
activation for exactly this window).
* Since both do write-then-read, at least one sees both halves and calls
`TenantApprovalService.TrySendWelcomeEmailAsync`.
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
sends: at most one wins. The marker is consumed strictly **after** the address check —
consuming it without an address would permanently suppress the mail.
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
# Examples
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
notification-email save path"; user saves address at T+69 s → save path sees approved
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
* Address saved before manual approve (classic shape): approval path finds the address,
wins the marker, sends; a later re-save of the same address does not re-send.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+7 -1
View File
@@ -20,6 +20,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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.
* [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.
@@ -28,22 +29,27 @@ https://docs.autopilotmonitor.com (separate repository).
* [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.
# 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 against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the six connections that also serve the document and the route chunks. Includes where browser telemetry actually lives.
* [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
+44 -1
View File
@@ -1,8 +1,51 @@
# Log
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 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). That origin serves the document, the route chunks and the RSC payloads over **HTTP/1.1**, so the six-connection-per-origin cap applies; 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 in-flight prefetches held every socket and the navigation the user wanted could not get one. 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 `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.
+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
+33 -2
View File
@@ -8,7 +8,7 @@ tags:
- backend
- gather-rules
- config
timestamp: 2026-07-20T00:00:00+02:00
timestamp: 2026-08-04T00:00:00+02:00
---
# Problem
@@ -64,6 +64,35 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
# Which "phase" feeds the triggers — the emitted timeline, not raw signals
The phase the executor sees is fed by `GatherRuleExecutorHost` from the **post-reduce
`TimelineEventStream`**: `EventTimelineEmitter` publishes every emitted event's
`(eventType, phase)` after the decision engine reduced the originating signal. Phase triggers
therefore fire exactly when the UI timeline shows the phase — by construction.
This replaced the original V2 wiring (`SignalIngress.SignalPosted`), which fires at enqueue
time with the **raw** collector payload. The raw phase can run minutes ahead of the
engine-declared one: in session `32312a32` (RealmJoin tenant) the Shell-Core ESP-exit
synthesized `EspPhaseChanged(FinalizingSetup)` at 12:18, while the engine — holding the
transition behind the RealmJoin completion gate until the first deployment resolved — declared
`phase_transition(FinalizingSetup)` at 12:25. A `phase_change` rule on `FinalizingSetup`
reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` fired 7 minutes early and read the key before
the RealmJoin package wrote it (`exists=false`).
Consequences of the emitted-event feed:
* `phase_change` / `phase_exit` / phase **scope** gates all follow the engine-reduced phase
(single feed, consistent with the portal's "collects once when the enrollment reaches …").
* `on_event` now sees **every emitted event type**, including engine-emitted ones
(`enrollment_complete`, `phase_transition`, `realmjoin_resolved`, …) that never existed on
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
emitted `EnrollmentEvent`).
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
off the raw signal stream deliberately — their collections are about OS state at the raw
boundary (ESP exit, keep-awake), not about timeline agreement.
# Scope semantics (agent, `GatherRuleExecutor`)
* Scope applies to **all trigger types**:
@@ -130,7 +159,9 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs` — scope gates, latch, canonical hash
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/GatherRuleExecutorHost.cs` — timeline-event trigger feed
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/TimelineEventStream.cs` — post-reduce observer surface
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
+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
+37 -1
View File
@@ -21,7 +21,7 @@ literal appears nowhere else.
| Component | Registry | Guard test |
|---|---|---|
| C# (Shared/Backend/Agent) | `AutopilotMonitor.Shared.Constants` (`*BaseUrl`) | `HardcodedUrlGuardTests` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| Web | `utils/config.ts` (`DOCS_URL`, `SITE_URL`, `PORTAL_URL`, `BOOTSTRAP_GO_URL`, `ENTRA_LOGIN_URL`, `API_URL_PROD`, `BLOB_URL_PROD`, `AGENT_DOWNLOAD_HOSTNAMES`) | `utils/__tests__/hardcodedUrls.guard.test.ts` |
| MCP | `src/config.ts` (`API_BASE_URL`, `ENTRA_LOGIN_BASE_URL`, `DOCS_BASE_URL`) | `src/__tests__/hardcoded-urls.guard.test.ts` |
Derived registries build on these instead of repeating hosts:
@@ -76,6 +76,42 @@ Deliberately still on the legacy account: the `HealthCheckService` /
`build-agent.yml` — they exist precisely to keep already-deployed customer
bootstrap scripts working until the customer migration completes.
# Bootstrap go-URL (2026-07-28)
The OOBE bootstrap short-URL moved from `www.autopilotmonitor.com/go/{code}`
(Next.js route, SSR) to **`go.autopilotmonitor.com/{code}`** — a Front Door
custom domain whose `/*` route rewrites onto `/api/bootstrap/go/{code}` on the
Function App (`GetBootstrapScriptFunction`). Registry entries:
`Constants.BootstrapGoBaseUrl` (C#, the single producer via
`CreateBootstrapSessionFunction`) and `BOOTSTRAP_GO_URL` (web, display-only in
the sessions list); `go.autopilotmonitor.com` is on both guards' enforced-host
lists. `CreateBootstrapSessionUrlShapeTests` pins the produced URL as an
independent oracle — before this, no test guarded the bootstrap URL shape at
all.
Front Door route caching for `bootstrap-go` is DISABLED — same hazard class as
the `/agent/*` route above, sharpened: the script body inlines the bearer
token, so a cache hit would replay one requester's token to another.
`NoStoreCacheMiddleware` additionally stamps `no-store` via the
`/api/bootstrap/go/` prefix.
Transition COMPLETED with the static-export PR (2026-07-29): the legacy
Next.js `/go/[code]` route, `utils/bootstrapValidation.ts`, and
`middleware.ts` are deleted — verified safe because the BootstrapSessions
table held zero active sessions predating the URL cutover (all expired
2026-03). The backend is now the only script producer;
`OobeBootstrapScriptGeneratorTests`' golden file remains as the pin of the
shipped template.
Portal detail routes are query-string based since the same PR
(`/sessions?id=…` etc., builders in `lib/routes.ts`, notification producer
`Constants.PortalSessionUrl`). Legacy PATH-shaped links in sent
Teams/Slack/webhook notifications are rewritten forever by SWA wildcard
rewrites + `components/LegacyPathRedirect.tsx`; `staticwebapp.config.json`
carries the production redirects/headers and is guarded by
`utils/__tests__/swaConfig.guard.test.ts` (registry-backed hosts, no
unsafe-eval, route order).
# Citations
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — C# registry
+49 -11
View File
@@ -1,7 +1,7 @@
---
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 against an HTTP/1.1 Static Web Apps runtime, and its latency tail starves the connection pool that serves the document and the route chunks.
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
@@ -25,21 +25,26 @@ it enters the viewport.
# Mechanism
Three properties combine into a portal-wide freeze:
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. **Shared origin over HTTP/1.1.** `portal.autopilotmonitor.com` serves the document, the JS route
chunks *and* the RSC payloads, and it negotiates HTTP/1.1 — not HTTP/2. Browsers cap concurrent
connections per origin at six.
3. **A heavy latency tail on the SSR runtime.** RSC responses are prerendered and edge-cached
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.
the Static Web Apps managed runtime queues them **server-side**, and the queue drains all at once.
When the tail hits, the in-flight prefetches hold all six connections. The document request and the
route chunks of the page the user actually wants then have no socket available, and the tab shows a
loading spinner or a blank page until the queue drains. Nothing fails — the requests eventually
return `200`, which is why the failure leaves no error trace anywhere.
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
@@ -62,6 +67,39 @@ document immediately and the browser then waited nearly four minutes for the `lo
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
+4 -4
View File
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
@description('Container image tag')
param imageTag string = 'latest'
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
@secure()
@description('Entra ID client secret for OAuth token exchange')
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
param oAuthStateSigningKey string = ''
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
param toolLoggingEnabled bool = false
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
param toolLoggingEnabled bool = true
// --- Azure Container Registry ---
+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"]
}
+87
View File
@@ -2687,6 +2687,93 @@
"truncated",
"coverage"
]
},
{
"ruleId": "ANALYZE-SEC-008",
"title": "Secure Boot is disabled",
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "note",
"operator": "not_exists",
"value": "",
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions": [
{
"signal": "secureboot_disabled",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiSecureBootEnabled",
"operator": "equals",
"value": "false",
"required": true
},
{
"signal": "physical_device",
"source": "event_data",
"eventType": "hardware_spec",
"dataField": "isVirtualMachine",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{
"signal": "physical_device",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
"remediation": [
{
"title": "Enable Secure Boot in UEFI firmware",
"steps": [
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title": "Check compliance impact",
"steps": [
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
},
{
"title": "Microsoft Learn: Re-enable Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": [
"security",
"secureboot",
"firmware",
"uefi",
"compliance"
]
}
]
}
+42
View File
@@ -161,3 +161,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
} else {
console.warn('guardrails.json not found — skipping guardrails generation');
}
// ── MCP rule-authoring module generation ────────────────────────────────────
// The MCP server exposes the rule schemas + guardrails as get_resource content
// so a customer's AI can author rules against the real contract instead of
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
// and the Docker build (rules/schema not copied) rule out importing the JSON
// directly — so we bake it into a TS module, exactly like the web mirror above.
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
// makes drift between rules/* and this generated module impossible to merge.
const mcpAuthoringOutput = path.resolve(
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
);
{
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
const ts = `/**
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
* Run: node rules/scripts/combine.js
*
* Consumed by the MCP server's rule-authoring surface (get_resource +
* validate_rule): the JSON Schemas are the validation contract, the guardrails
* are the agent-side collection allowlists. Single source of truth is rules/;
* the CI guardrails-in-sync job guards this file against drift.
*/
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
`;
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
}
@@ -0,0 +1,307 @@
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
#
# 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-dev.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' {
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
# 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\LeonGottschalk' }
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
)
}
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\LeonGottschalk' }
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\LeonGottschalk')
}
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 (W365 field case)' {
$p = New-FakeProfile 'LeonGottschalk' 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 'LeonGottschalk' 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, v2.2 behaviour)' {
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 (W365 field case)' {
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 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\LeonGottschalk' } } -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, v2.3-dev.3)' {
# 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'
}
}
+376 -222
View File
@@ -1,13 +1,24 @@
<#
.SYNOPSIS
Bootstrap script to deploy and start the Autopilot Monitor agent. (DEV)
DEV bootstrap script (v2.3-dev) to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
DEV VARIANT of Install-AutopilotMonitor.ps1 for validating the v2.3 Cloud-PC-relax
on Windows 365 before promoting it to the production script. Differences to v2.2:
- Cloud-PC-relax (see below) as a second trigger for the guard 2+3 exemption
- Raw OutOfBoxExperienceState value is logged (diagnosis)
- Logs to bootstrap_agent_dev.log so runs never interleave with the prod script
- Testable structure: guard/relax logic lives in functions; the script only
executes when run directly. Dot-sourcing it (as the Pester suite in
Install-AutopilotMonitor-dev.Tests.ps1 does) loads the functions without
side effects.
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).
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 (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
@@ -15,18 +26,36 @@
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.
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 (Test via WinRT
SystemSetupInfo.OutOfBoxExperienceState).
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
complete before any user ever connects, so OutOfBoxExperienceState is NEVER
InProgress when this script runs. The assigned user's profile is created at
first connect, typically seconds before IME pulls this script. Trigger: the
device positively identifies as a Cloud PC via TWO local markers (both
required; captured from a real Cloud PC -- local WMI reports only the
generic 'Virtual Machine' model): 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 for why boot uptime is meaningless there.
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.
Known accepted edge: a W365 Frontline shared-mode device can legitimately show a
single fresh profile while productive; a mis-install there is one-time (agent
finds enrollment complete and self-destructs; Deployed marker prevents repeats).
.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.
@@ -36,11 +65,16 @@
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.
Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs
headless for days between provisioning and the user's first connect, and the
phase worth monitoring (Account Setup) only starts at that first connect --
there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor,
not boot uptime.
.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.
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.
@@ -50,12 +84,34 @@
- 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:
the class -> state reads as 'Unavailable' -> 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-08-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is
active: Cloud PCs run headless for days before the user's first
connect, so boot uptime says nothing about enrollment freshness --
the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already
anchors the relax in time. OOBE-restore keeps the uptime guard.
2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field
test proved local WMI reports only Manufacturer='Microsoft
Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model
string exists only in Intune/Graph). Detection now requires the
HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
CloudManagedDesktopExtension service (both verified present on a
real Cloud PC); raw identity + marker states are always logged.
2026-08-05 v2.3-dev DEV variant (-dev.ps1, own log file bootstrap_agent_dev.log)
to validate on Windows 365 before promotion to v2.3:
Cloud-PC-relax -- a positively identified Cloud PC
(Win32_ComputerSystem Manufacturer 'Microsoft Corporation' +
Model 'Cloud PC*') now triggers the guard 2+3 relax alongside
OOBE InProgress; the single-fresh-profile conditions are
unchanged. Raw OutOfBoxExperienceState is now logged.
Refactored into functions with a dot-source entry guard so the
Pester suite (*.Tests.ps1) can load the logic 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/*).
@@ -69,16 +125,7 @@
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.
(older entries: see Install-AutopilotMonitor.ps1)
#>
[CmdletBinding()]
@@ -97,97 +144,167 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
$ScriptVersion = "2.3-dev.3"
# 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
$LogFile = "$AgentLogPath\bootstrap_agent_dev.log"
# 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 and turn them into arrays. Write-Host goes to the console host only.
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
}
# Positive OOBE check via the documented WinRT API
# Raw OOBE state 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
# 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).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
function Get-OobeState {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
}
catch {
return 'Unavailable'
}
}
# Positive Windows 365 Cloud PC detection.
# Evidence captured 2026-08-06 on a real, freshly provisioned W365 Enterprise Cloud PC:
# - Win32_ComputerSystem is GENERIC there: Manufacturer='Microsoft Corporation',
# Model='Virtual Machine'. The 'Cloud PC Enterprise ...' model string exists only
# in the Intune/Graph inventory, NOT in local WMI/SMBIOS -- it cannot be used here.
# - HKLM:\SOFTWARE\Microsoft\Windows365 exists.
# - Service 'CloudManagedDesktopExtension' (Microsoft Cloud Managed Desktop
# Extension, the W365 management agent that runs ON Cloud PCs) is installed.
# BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
# physical clients may carry Windows365 policy state; the MCMD agent family also
# served other managed-desktop offerings). Raw identity and both marker states are
# always logged for field 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
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# 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 }
}
# 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)
}
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
$profilePaths = @(Get-RealUserProfilePaths)
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
# 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) {
# unless the relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
@@ -195,26 +312,36 @@ try {
"{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
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
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
# (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*' -and -not $oobeRelax) {
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.
# Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the
# user's first connect -- often days -- and the phase worth monitoring (Account Setup)
# only STARTS at that first connect. Boot uptime is therefore meaningless there; the
# single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time
# anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical
# device, uptime is meaningful).
$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)
@@ -222,148 +349,175 @@ try {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
}
}
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# 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
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
$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 (oobeRelax=$oobeRelax)"
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"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
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
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
}
}
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-dev.ps1", as the Pester suite does) only loads
# the functions above and MUST stay side-effect free.
if ($MyInvocation.InvocationName -ne '.') {
Invoke-Bootstrap
}
+138 -81
View File
@@ -5,18 +5,28 @@
.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.
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
(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.
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.
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.
@@ -27,16 +37,35 @@
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
@@ -55,17 +84,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.2"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -85,26 +117,19 @@ function Write-Log {
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
function Get-FileMd5Base64 {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$md5 = [System.Security.Cryptography.MD5]::Create()
# 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 {
$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() -eq 'InProgress'
}
finally {
$md5.Dispose()
catch {
return $false
}
}
@@ -119,30 +144,65 @@ try {
exit 0
}
# 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)$'
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
$profileNames = @(
# 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 {
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." }
$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)
}
(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."
# 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.
# 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*') {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
}
@@ -166,7 +226,7 @@ try {
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
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"
@@ -181,38 +241,47 @@ 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
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)"
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]
}
}
}
catch {
Write-Log "WARNING: Could not fetch manifest - falling back to legacy MD5 check: $($_.Exception.Message)"
}
$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 `
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop `
-PassThru
Write-Log "Downloaded agent to $zipPath"
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
@@ -227,29 +296,17 @@ try {
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
if ($expectedSha256) {
$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"
# 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."
}
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"
}
$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"
@@ -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 ''
+73 -36
View File
@@ -1,8 +1,11 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent'
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
[int]$OobeProfileMaxAgeMinutes = 15
)
$ErrorActionPreference = 'SilentlyContinue'
@@ -38,6 +41,19 @@ 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
}
}
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -58,63 +74,79 @@ 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 }
# 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 -- 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 OOBE-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
@@ -134,7 +166,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 +200,11 @@ 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"
@@ -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.
@@ -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]
@@ -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);
}
}
}
@@ -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
@@ -92,6 +92,27 @@ 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.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]
@@ -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));
}
}
}
@@ -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]
@@ -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()
{
@@ -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));
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock,
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool)
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null)
{
CapturedPatternIds = whiteGloveSealingPatternIds;
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
@@ -0,0 +1,189 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
/// fires only when the phase declaration is actually emitted.
/// Rules use the absent-registry-path pattern so no real system state is touched; execution
/// is observed via the InformationalEvent posts the host sends through its ingress sink.
/// </summary>
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorHostTimelineFeedTests : IDisposable
{
private const string AbsentPath =
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_5b1f03a2-0000-0000-0000-000000000000";
private readonly TempDirectory _tmp = new TempDirectory();
private readonly CapturingSink _sink = new CapturingSink();
private readonly TimelineEventStream _stream = new TimelineEventStream();
private GatherRuleExecutorHost? _host;
public void Dispose()
{
_host?.Dispose();
_tmp.Dispose();
}
private sealed class CapturingSink : ISignalIngressSink
{
private readonly List<string> _eventTypes = new List<string>();
private readonly object _gate = new object();
public int CountOf(string eventType)
{
lock (_gate)
{
var n = 0;
foreach (var t in _eventTypes)
if (string.Equals(t, eventType, StringComparison.Ordinal)) n++;
return n;
}
}
public void Post(
DecisionSignalKind kind,
DateTime occurredAtUtc,
string sourceOrigin,
Evidence evidence,
IReadOnlyDictionary<string, string>? payload = null,
int kindSchemaVersion = 1,
object? typedPayload = null)
{
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)) return;
lock (_gate) _eventTypes.Add(eventType);
}
}
private void BuildHost(params GatherRule[] rules)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_host = new GatherRuleExecutorHost(
sessionId: "sess",
tenantId: "tenant",
ingress: _sink,
clock: new SystemClock(),
logger: logger,
rules: new List<GatherRule>(rules),
imeLogPathOverride: null,
unrestrictedMode: true,
gatherDebugLogPath: null,
timelineEvents: _stream);
_host.Start();
}
private bool WaitForCount(string eventType, int count, int timeoutMs = 10000)
{
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds < timeoutMs)
{
if (_sink.CountOf(eventType) >= count) return true;
Thread.Sleep(25);
}
return _sink.CountOf(eventType) >= count;
}
private static GatherRule Rule(string id, string trigger, string? triggerPhase = null, string? triggerEventType = null)
=> new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "registry",
Target = AbsentPath,
Trigger = trigger,
TriggerPhase = triggerPhase ?? string.Empty,
TriggerEventType = triggerEventType ?? string.Empty,
OutputEventType = "gather_host_test",
Enabled = true,
};
[Fact]
public void PhaseChangeRule_FiresOnEmittedPhaseDeclaration_NotOnPhaselessEvents()
{
BuildHost(Rule("GATHER-HOST-001", "phase_change", triggerPhase: "FinalizingSetup"));
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
// Only the engine's actual phase declaration fires the rule.
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
Assert.True(WaitForCount("gather_host_test", 1));
}
[Fact]
public void OnEventRule_FiresForEngineEmittedEventTypes()
{
// enrollment_complete is an engine-emitted effect event; it never existed on the raw
// InformationalEvent signal stream, so the docs-promised "On Event with
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
Assert.True(WaitForCount("gather_host_test", 1));
}
[Fact]
public void PhaseFeed_DedupsConsecutiveSamePhaseDeclarations()
{
// "Any phase" phase_change rule fires on every phase TRANSITION — a re-declared
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
Assert.True(WaitForCount("gather_host_test", 1));
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
Thread.Sleep(300);
Assert.Equal(1, _sink.CountOf("gather_host_test"));
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
Assert.True(WaitForCount("gather_host_test", 2));
}
[Fact]
public void PhaseExitRule_FiresWhenEmittedTimelineLeavesThePhase()
{
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
Assert.True(WaitForCount("gather_host_test", 1));
}
[Fact]
public void StoppedHost_NoLongerReactsToTheStream()
{
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
_host!.Stop();
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
Thread.Sleep(300);
Assert.Equal(0, _sink.CountOf("gather_host_test"));
}
}
}
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
// same facts signal (safe default "false" off-device).
var selfDeploying = payload["isSelfDeployingProfile"];
Assert.True(selfDeploying == "true" || selfDeploying == "false");
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
// off-device; a build host is not a Cloud PC).
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
var cloudPc = payload["isCloudPc"];
Assert.True(cloudPc == "true" || cloudPc == "false");
}
[Fact]
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
/// Windows365 policy state; the MCMD agent family served other managed-desktop
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
/// </summary>
public sealed class CloudPcDetectorTests
{
private static Func<string, bool> Probe(params string[] existingKeys)
{
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
return path => set.Contains(path);
}
[Fact]
public void Both_markers_present_resolves_true()
{
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
CloudPcDetector.Windows365MarkerKey,
CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void Windows365_key_alone_resolves_false()
{
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
}
[Fact]
public void Cmde_service_alone_resolves_false()
{
// MCMD agent family also served other managed-desktop offerings.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void No_markers_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
}
[Fact]
public void Null_probe_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
}
[Fact]
public void Throwing_probe_degrades_to_false()
{
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
}
[Fact]
public void DetectIsCloudPc_never_throws_on_build_host()
{
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
// A build host is not a Cloud PC, but the contract under test is "no throw",
// not the detected value.
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
Assert.Null(ex);
}
}
}
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(Environment.MachineName, captured.DeviceName);
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
Assert.True(captured.IsUserDriven);
// W365 marker rides the registration body. A build host is not a Cloud PC
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
// detector's SKIP-safe default is what must land here.
Assert.False(captured.IsCloudPc);
}
[Fact]
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-do", 0);
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
adapter.TriggerDoTelemetryFromTest(app);
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
adapter.TriggerDoTelemetryFromTest(app);
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
}
[Fact]
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
{
// Session df1fcf47: the DO collector observed the completion live, but the event
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
// the past — which dragged Sessions.StartedAt back and inflated the duration.
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
// last matched line.
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-do-poll", 0);
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
Assert.Equal(ClockNow, info.OccurredAtUtc);
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
// Not a replay — live observation must never trip the replay suppression,
// even with a stale unrelated line on the tracker.
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
}
[Fact]
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
int priorDoCalls = 0;
int priorScriptCalls = 0;
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.OnImeAgentVersion("1.0.0.0");
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
Assert.Equal(1, priorVersionCalls);
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
{
using var f = new ImeLogTrackerAdapterFixture();
Action<string> priorVersion = _ => { };
Action<AppPackageState> priorDo = _ => { };
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
Action<ScriptExecutionState> priorScript = _ => { };
f.Tracker.OnImeAgentVersion = priorVersion;
f.Tracker.OnDoTelemetryReceived = priorDo;
@@ -153,6 +153,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
Assert.Equal("Device Setup", (string?)parsed["PhaseName"]);
}
[Fact]
public void Emit_publishes_eventType_and_parsed_phase_to_TimelineEventStream()
{
// Post-reduce observer surface: gather-rule phase/event triggers subscribe here so
// they fire in step with what the timeline shows (session 32312a32 regression).
using var r = new Rig();
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
var published = new List<(string EventType, EnrollmentPhase Phase)>();
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
var sut = new EventTimelineEmitter(r.Inner, stream);
sut.Emit(
new Dictionary<string, string>
{
["eventType"] = "phase_transition",
["phase"] = "FinalizingSetup",
},
State(),
At);
sut.Emit(
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
State(),
At);
Assert.Equal(2, published.Count);
Assert.Equal(("phase_transition", EnrollmentPhase.FinalizingSetup), published[0]);
Assert.Equal(("enrollment_complete", EnrollmentPhase.Unknown), published[1]);
// Publish happens AFTER the transport enqueue — wire ordering is authoritative.
Assert.Equal(2, r.Transport.EnqueueCount);
}
[Fact]
public void TimelineEventStream_subscriber_exception_does_not_break_the_emit_path()
{
using var r = new Rig();
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
var secondSubscriberCalls = 0;
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
var sut = new EventTimelineEmitter(r.Inner, stream);
sut.Emit(
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
State(),
At);
// Emit neither threw (would surface as an EffectRunner retry = duplicated event)
// nor skipped the remaining subscribers.
Assert.Equal(1, r.Transport.EnqueueCount);
Assert.Equal(1, secondSubscriberCalls);
}
[Fact]
public void Phase_parameter_with_invalid_value_falls_back_to_Unknown()
{
@@ -511,6 +511,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
$"network_bandwidth_estimate (@{bandwidthIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the straggler stays in Part 1.");
}
// ===================================================== WG Part-1 preprov diagnostics
// V1 parity (restored 2026-07-28): the seal exit uploads the diagnostics package as a
// pre-provisioning intermediate under the SAME mode gating as the terminal path — the
// seal counts as a success, so Always uploads, OnFailure skips (a Part-1 failure
// terminates via the regular Failed path, which uploads), Off does nothing.
[Theory]
[InlineData("Always", true, 1)]
[InlineData("OnFailure", true, 0)]
[InlineData("Off", true, 0)]
[InlineData("Always", false, 0)]
public void Handle_whiteglove_part1_diagnostics_upload_respects_mode(
string mode, bool enabled, int expected)
{
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode);
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(expected, rig.DiagnosticsUploads);
}
[Fact]
public void Handle_whiteglove_part1_diagnostics_upload_uses_preprov_suffix_and_succeeded_flag()
{
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(1, rig.DiagnosticsUploads);
Assert.Equal("preprov", rig.LastDiagnosticsSuffix);
Assert.Equal(true, rig.LastDiagnosticsSucceededFlag);
}
[Fact]
public void Handle_whiteglove_part1_diagnostics_events_precede_part1_complete_marker()
{
// whiteglove_part1_complete is the authoritative Part-1/Part-2 boundary for the
// web split (computeWhiteGloveSplitSequence) — diagnostics_collecting/uploaded
// must sequence BEFORE it or they get mis-filed into the resumed Part-2 block.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" };
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
var emitted = rig.EmittedEventTypes.ToList();
var collectingIdx = emitted.IndexOf("diagnostics_collecting");
var uploadedIdx = emitted.IndexOf("diagnostics_uploaded");
var part1CompleteIdx = emitted.IndexOf(Constants.EventTypes.WhiteGlovePart1Complete);
Assert.True(collectingIdx >= 0, "diagnostics_collecting must have been emitted.");
Assert.True(uploadedIdx >= 0, "diagnostics_uploaded must have been emitted.");
Assert.True(part1CompleteIdx >= 0, "whiteglove_part1_complete must have been emitted.");
Assert.True(uploadedIdx < part1CompleteIdx,
$"diagnostics_uploaded (@{uploadedIdx}) must precede whiteglove_part1_complete (@{part1CompleteIdx}) so the upload stays in Part 1.");
}
[Fact]
public void Handle_whiteglove_part1_still_skips_cleanup_and_marker_when_diagnostics_enabled()
{
// The preprov upload must not change the Part-1 exit contract: no self-destruct,
// no enrollment-complete.marker, whiteglove.complete still written.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always");
rig.Build(cfg).Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.Equal(0, rig.CleanupService.Invocations);
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
}
[Fact]
public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
{
@@ -121,6 +121,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public string ImeMatchLogPath { get; set; }
/// <summary>
/// Optional path to write a gather-rule evaluation trace (for debugging).
/// If empty, no trace is written. Set from remote config (EnableGatherRuleDebugLog)
/// or the --gather-debug-log CLI override.
/// </summary>
public string GatherRuleDebugLogPath { get; set; }
/// <summary>
/// Path to directory with real IME log files for log replay.
/// When set, the agent replays these logs with time compression instead of reading live logs.
@@ -135,6 +135,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
}
}
// ---------------- GatherRuleDebugLog: same bool→path projection as ImeMatchLog
// above. `true` expands Constants.GatherRuleDebugLogPath, `false`
// nulls the runtime path unless a --gather-debug-log CLI override
// supplied a non-default path.
if (remoteConfig.EnableGatherRuleDebugLog)
{
agentConfig.GatherRuleDebugLogPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
}
else
{
var defaultGatherDebugPath = Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
if (string.IsNullOrEmpty(agentConfig.GatherRuleDebugLogPath)
|| string.Equals(agentConfig.GatherRuleDebugLogPath, defaultGatherDebugPath, StringComparison.OrdinalIgnoreCase))
{
agentConfig.GatherRuleDebugLogPath = null;
}
}
// ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
// misconfigured tenant can in theory break uploads, but this
// surfaces the config bug instead of silently masking it).
@@ -327,6 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
KeepLogFile = false,
EnableGeoLocation = true,
EnableImeMatchLog = false,
EnableGatherRuleDebugLog = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
_stateDirty = true;
}
catch (Exception ex)
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
// log line, null when the DO collector observed the completion live via polling. The
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
/// <summary>
@@ -631,10 +631,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// the regular <see cref="ProcessRecord"/> pipeline with isBackfill=true, so they arrive
/// as modern_deployment_error events carrying backfilled=true plus the original
/// timeCreated; the event's own timestamp is emission time, so session StartedAt is not
/// dragged backwards. Newest-first, capped, watermark-deduped across restarts.
/// dragged backwards. The scan reads newest-first (the cap keeps the newest records),
/// but replay is buffered so an Info summary event announces the block BEFORE the errors
/// hit the timeline (customer feedback 2026-07-28: an unannounced wall of pre-agent
/// Error events reads like a live failure) and the records emit oldest-first, giving the
/// timeline sequence the original event-log chronology. Watermark-deduped across restarts.
/// </summary>
private void BackfillAutopilotErrors()
{
var buffered = new List<EventRecord>();
try
{
long watermark;
@@ -649,7 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
ReverseDirection = true
};
int processed = 0, skippedWatermark = 0, droppedOverCap = 0;
int skippedWatermark = 0, droppedOverCap = 0;
long maxSeen = watermark;
using (var reader = new EventLogReader(query))
@@ -657,37 +662,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
var recordId = record.RecordId ?? 0;
if (recordId > maxSeen) maxSeen = recordId;
if (recordId != 0 && recordId <= watermark)
{
var recordId = record.RecordId ?? 0;
if (recordId > maxSeen) maxSeen = recordId;
if (recordId != 0 && recordId <= watermark)
{
skippedWatermark++;
continue;
}
if (processed >= ErrorBackfillMaxRecords)
{
droppedOverCap++;
continue;
}
processed++;
ProcessRecord(record, "Autopilot", AutopilotChannel, isBackfill: true);
skippedWatermark++;
record.Dispose();
continue;
}
if (buffered.Count >= ErrorBackfillMaxRecords)
{
droppedOverCap++;
record.Dispose();
continue;
}
buffered.Add(record);
}
}
if (maxSeen > watermark)
AdvanceAutopilotErrorWatermark(maxSeen);
if (processed == 0 && droppedOverCap == 0)
if (buffered.Count == 0 && droppedOverCap == 0)
{
_logger.Debug($"ModernDeployment error backfill: nothing new (skippedByWatermark={skippedWatermark})");
}
else
_logger.Info($"ModernDeployment error backfill: forwarded {processed} record(s) " +
{
EmitErrorBackfillSummary(buffered.Count, droppedOverCap);
// Scan buffered newest-first — replay in reverse so the emitted sequence
// follows the original event-log chronology.
for (var i = buffered.Count - 1; i >= 0; i--)
{
ProcessRecord(buffered[i], "Autopilot", AutopilotChannel, isBackfill: true);
}
_logger.Info($"ModernDeployment error backfill: forwarded {buffered.Count} record(s) " +
$"(skippedByWatermark={skippedWatermark}, droppedOverCap={droppedOverCap})");
}
}
catch (EventLogNotFoundException)
{
@@ -701,6 +717,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
_logger.Warning($"ModernDeployment error backfill failed: {ex.Message}");
}
finally
{
foreach (var record in buffered)
{
try { record.Dispose(); } catch { }
}
}
}
/// <summary>
/// Announces the error-backfill block on the session timeline BEFORE the replayed
/// errors, so pre-agent Error events are readable as history rather than a live
/// failure. The web timeline additionally badges each replayed event via its
/// backfilled=true payload marker; this summary carries backfillSummary=true instead —
/// it is a live emission, not a replayed record.
/// </summary>
internal void EmitErrorBackfillSummary(int replayedCount, int droppedOverCap)
{
var message = $"[Autopilot] Replaying {replayedCount} error event(s) recorded in the Autopilot event log " +
$"before the agent started (lookback {ErrorBackfillLookbackMinutes / 60}h). " +
"These predate monitoring — if enrollment progressed afterwards, they were already resolved by a retry.";
if (droppedOverCap > 0)
{
message += $" {droppedOverCap} older record(s) beyond the {ErrorBackfillMaxRecords}-record cap were not replayed.";
}
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.ModernDeploymentLog,
Severity = EventSeverity.Info,
Source = "ModernDeploymentWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = new Dictionary<string, object>
{
{ "backfillSummary", true },
{ "replayedCount", replayedCount },
{ "droppedOverCap", droppedOverCap },
{ "lookbackMinutes", ErrorBackfillLookbackMinutes }
}
});
}
/// <summary>
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -0,0 +1,221 @@
using System;
using System.Diagnostics;
using AutopilotMonitor.Agent.V2.Core.Security;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Fallback WiFi reader via <c>netsh wlan show interfaces</c> output parsing, used ONLY
/// when <see cref="WifiInfoReader"/> (native WLAN API) yields nothing on a WiFi-connected
/// device. Field evidence 2026-07-28: the WLAN API can return nothing in the OOBE/SYSTEM
/// context on builds where inbox netsh still reports the connection — until that is fully
/// root-caused (suspect: Wi-Fi location gating, 24H2+), netsh stays as the safety net.
/// <para>
/// netsh labels follow the OS UI language, so the parser matches the label variants of
/// every language implied by the enrollment geography seen to date (see the comment on
/// the key arrays for the country→language derivation). Unknown-language labels fall
/// through harmlessly — the affected field is simply absent, same as before. "SSID"
/// itself is untranslated in all known locales.
/// </para>
/// </summary>
internal static class NetshWifiFallback
{
private const int NetshTimeoutMs = 5000;
// Label variants per field, by OS UI language. Matched case-insensitively against the
// full text left of the colon, so acronym-suffixed keys ("AP BSSID") never collide.
// Language set derived from enrollment geography (get_geographic_metrics, 365d):
// US/GB/AU/CA/IE/SG/IN/ZA/NG/PH en · DE/AT/CH de · BE/NL nl · BE/FR/CH fr · DK da ·
// SE sv · PL pl · TR tr · CN zh-CN · HK zh-TW · ES/MX/PE/PY es · CZ cs · SK sk ·
// IT/CH it · ID id · MY ms · FI fi · LT lt · LV lv · HU hu · BR/PT pt · JP ja ·
// KR ko · TH th · VN vi · RO/MD ro · BG bg · BA/HR hr · AE/SA/EG/JO ar ·
// MN/GE/AZ usually en/ru images → ru.
private static readonly string[] SignalKeys =
{
"Signal", // en, de, fr, sv, da, nb, hr
"Señal", // es
"Sinal", // pt
"Segnale", // it
"Signaal", // nl
"Sygnał", // pl
"Signál", // cs, sk
"Sinyal", // tr, id
"Isyarat", // ms
"Signaali", // fi
"Signalas", // lt
"Signāls", // lv
"Jel", // hu
"Semnal", // ro
"Tín hiệu", // vi
"สัญญาณ", // th
"الإشارة", // ar
"إشارة", // ar (variant without article)
"Сигнал", // ru, bg
"シグナル", // ja
"信号", // zh-CN
"訊號", // zh-TW
"신호", // ko
};
private static readonly string[] RadioTypeKeys =
{
"Radio type", // en
"Funktyp", // de
"Type de radio", // fr
"Tipo de radio", // es
"Tipo de rádio", // pt
"Tipo di radio", // it
"Radiotype", // nl, da
"Radiotyp", // sv
"Typ radia", // pl
"Typ rádia", // cs, sk
"Radyo türü", // tr
"Jenis radio", // id, ms
"Radiotyyppi", // fi
"Radijo tipas", // lt
"Radio tips", // lv
"Rádió típusa", // hu
"Tip radio", // ro
"Vrsta radija", // hr, bs
"Loại radio", // vi
"ชนิดวิทยุ", // th
"نوع الراديو", // ar
"Тип радио", // ru
"Тип на радиото", // bg
"無線の種類", // ja
"无线电类型", // zh-CN
"無線電類型", // zh-TW
"라디오 종류", // ko
"무선 종류", // ko (variant)
};
private static readonly string[] ChannelKeys =
{
"Channel", // en
"Kanal", // de, sv, da, nb, tr, hr
"Canal", // fr, es, pt, ro
"Canale", // it
"Kanaal", // nl
"Kanał", // pl
"Kanál", // cs, sk
"Saluran", // id, ms
"Kanava", // fi
"Kanalas", // lt
"Kanāls", // lv
"Csatorna", // hu
"Kênh", // vi
"ช่องสัญญาณ", // th
"ช่อง", // th (variant)
"القناة", // ar
"قناة", // ar (variant without article)
"Канал", // ru, bg
"チャネル", // ja
"信道", // zh-CN
"通道", // zh-TW / zh-CN (variant)
"채널", // ko
};
/// <summary>
/// Runs <c>netsh wlan show interfaces</c> and parses its output. Returns a
/// <see cref="WifiConnectionInfo"/> with the fields that could be parsed, or null when
/// nothing was found (no WLAN, netsh failure, timeout, or unrecognized localized
/// labels). Never throws.
/// </summary>
internal static WifiConnectionInfo TryRead()
{
try
{
var psi = new ProcessStartInfo
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(NetshTimeoutMs))
{
try { process.Kill(); } catch { }
return null;
}
}
return Parse(output);
}
catch
{
return null;
}
}
/// <summary>
/// Pure parser over the netsh output text. Internal for direct unit-testing via
/// InternalsVisibleTo (TryRead itself spawns a process and stays untested).
/// </summary>
internal static WifiConnectionInfo Parse(string output)
{
if (string.IsNullOrEmpty(output))
return null;
string ssid = null, radioType = null;
int? signal = null, channel = null;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (MatchesAny(key, SignalKeys))
{
// Value formats seen: "94%", "94% " (trailing space), "94 %".
if (int.TryParse(value.TrimEnd('%', ' '), out var sig))
signal = sig;
}
else if (MatchesAny(key, RadioTypeKeys))
{
radioType = value;
}
else if (MatchesAny(key, ChannelKeys))
{
if (int.TryParse(value, out var ch))
channel = ch;
}
}
if (ssid == null && signal == null && radioType == null && channel == null)
return null;
return new WifiConnectionInfo
{
Ssid = ssid,
SignalPercent = signal,
RadioType = radioType,
Channel = channel,
};
}
private static bool MatchesAny(string key, string[] candidates)
{
foreach (var candidate in candidates)
{
if (key.Equals(candidate, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}
@@ -0,0 +1,234 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using static AutopilotMonitor.Agent.V2.Core.Monitoring.Interop.WlanNativeMethods;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Current WiFi connection details as read from the native WLAN API.
/// </summary>
public sealed class WifiConnectionInfo
{
/// <summary>SSID of the connected network; null when the AP broadcasts an empty SSID.</summary>
public string Ssid { get; internal set; }
/// <summary>
/// Signal quality 0–100 (same semantics as the netsh "Signal" percentage). Always set
/// on the native WLAN API path; null only when the netsh fallback could not parse it.
/// </summary>
public int? SignalPercent { get; internal set; }
/// <summary>Human-readable PHY generation ("802.11ax" …); null for exotic/unknown PHYs.</summary>
public string RadioType { get; internal set; }
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
}
/// <summary>
/// Fail-soft reader over <see cref="WlanNativeMethods"/>: returns the current WiFi
/// connection or null — never throws, never blocks on a child process. Machines without
/// the WLAN AutoConfig service (VMs, wired-only devices) simply return null.
/// </summary>
public static class WifiInfoReader
{
/// <summary>
/// Reads the current WiFi connection. When <paramref name="preferredInterfaceId"/> is
/// given (the adapter GUID from <c>NetworkInterface.Id</c>), that interface is queried
/// first; otherwise — or when it is not connected — the first connected WLAN interface
/// wins. Returns null when no interface has an active connection.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId = null)
=> TryGetCurrentConnection(preferredInterfaceId, out _);
/// <summary>
/// Same as <see cref="TryGetCurrentConnection(Guid?)"/> but reports WHY the read
/// yielded nothing via <paramref name="diagnostics"/> (native return codes per step).
/// The WLAN API can fail in ways that are invisible from the outside (e.g. per-process
/// access gating on newer Windows builds) — callers log this at Debug so field issues
/// are explainable from the agent log instead of a silent missing event.
/// </summary>
public static WifiConnectionInfo TryGetCurrentConnection(Guid? preferredInterfaceId, out string diagnostics)
{
var diag = new StringBuilder();
var clientHandle = IntPtr.Zero;
try
{
var rc = WlanOpenHandle(WLAN_CLIENT_VERSION, IntPtr.Zero, out _, out clientHandle);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanOpenHandle rc={rc}";
return null;
}
var interfaceList = IntPtr.Zero;
try
{
rc = WlanEnumInterfaces(clientHandle, IntPtr.Zero, out interfaceList);
if (rc != ERROR_SUCCESS)
{
diagnostics = $"WlanEnumInterfaces rc={rc}";
return null;
}
var count = (uint)Marshal.ReadInt32(interfaceList);
if (count == 0)
{
diagnostics = "no WLAN interfaces";
return null;
}
var infoSize = Marshal.SizeOf(typeof(WLAN_INTERFACE_INFO));
// List header: DWORD dwNumberOfItems + DWORD dwIndex, then the info array.
var firstEntry = new IntPtr(interfaceList.ToInt64() + 8);
WifiConnectionInfo fallback = null;
for (var i = 0; i < count; i++)
{
var entryPtr = new IntPtr(firstEntry.ToInt64() + i * infoSize);
var info = (WLAN_INTERFACE_INFO)Marshal.PtrToStructure(entryPtr, typeof(WLAN_INTERFACE_INFO));
var connection = QueryCurrentConnection(clientHandle, info.InterfaceGuid, diag);
if (connection == null)
continue;
if (preferredInterfaceId.HasValue && info.InterfaceGuid == preferredInterfaceId.Value)
{
diagnostics = null;
return connection;
}
if (fallback == null)
fallback = connection;
}
diagnostics = fallback == null ? diag.ToString() : null;
return fallback;
}
finally
{
if (interfaceList != IntPtr.Zero)
WlanFreeMemory(interfaceList);
}
}
catch (Exception ex)
{
diagnostics = $"exception {ex.GetType().Name}: {ex.Message}";
return null;
}
finally
{
if (clientHandle != IntPtr.Zero)
WlanCloseHandle(clientHandle, IntPtr.Zero);
}
}
private static WifiConnectionInfo QueryCurrentConnection(IntPtr clientHandle, Guid interfaceGuid, StringBuilder diag)
{
var dataPtr = IntPtr.Zero;
try
{
var rc = WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_current_connection,
IntPtr.Zero, out _, out dataPtr, IntPtr.Zero);
if (rc != ERROR_SUCCESS)
{
diag.Append($"[{interfaceGuid:D}] query(current_connection) rc={rc}; ");
return null;
}
var attributes = (WLAN_CONNECTION_ATTRIBUTES)Marshal.PtrToStructure(
dataPtr, typeof(WLAN_CONNECTION_ATTRIBUTES));
if (attributes.isState != WLAN_INTERFACE_STATE.wlan_interface_state_connected)
{
diag.Append($"[{interfaceGuid:D}] state={attributes.isState}; ");
return null;
}
var assoc = attributes.wlanAssociationAttributes;
return new WifiConnectionInfo
{
Ssid = DecodeSsid(assoc.dot11Ssid.ucSSID, assoc.dot11Ssid.uSSIDLength),
SignalPercent = (int)Math.Min(assoc.wlanSignalQuality, 100),
RadioType = PhyTypeToRadioType(assoc.dot11PhyType),
Channel = QueryChannel(clientHandle, interfaceGuid),
};
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
private static int? QueryChannel(IntPtr clientHandle, Guid interfaceGuid)
{
var dataPtr = IntPtr.Zero;
try
{
if (WlanQueryInterface(clientHandle, ref interfaceGuid,
WLAN_INTF_OPCODE.wlan_intf_opcode_channel_number,
IntPtr.Zero, out var dataSize, out dataPtr, IntPtr.Zero) != ERROR_SUCCESS)
return null;
if (dataSize < sizeof(uint) || dataPtr == IntPtr.Zero)
return null;
return Marshal.ReadInt32(dataPtr);
}
catch
{
return null;
}
finally
{
if (dataPtr != IntPtr.Zero)
WlanFreeMemory(dataPtr);
}
}
/// <summary>
/// Decodes a raw DOT11_SSID (UTF-8 by convention) into a string; null for empty SSIDs.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string DecodeSsid(byte[] raw, uint length)
{
if (raw == null || length == 0)
return null;
var byteCount = (int)Math.Min(length, (uint)raw.Length);
try
{
var ssid = Encoding.UTF8.GetString(raw, 0, byteCount);
return string.IsNullOrEmpty(ssid) ? null : ssid;
}
catch
{
return null;
}
}
/// <summary>
/// Maps a DOT11 PHY type to the same strings netsh historically reported as
/// "Radio type" — keeps event payloads consistent across the netsh→WLAN-API switch.
/// Internal for direct unit-testing via InternalsVisibleTo.
/// </summary>
internal static string PhyTypeToRadioType(DOT11_PHY_TYPE phyType)
{
switch (phyType)
{
case DOT11_PHY_TYPE.dot11_phy_type_ofdm: return "802.11a";
case DOT11_PHY_TYPE.dot11_phy_type_hrdsss: return "802.11b";
case DOT11_PHY_TYPE.dot11_phy_type_erp: return "802.11g";
case DOT11_PHY_TYPE.dot11_phy_type_ht: return "802.11n";
case DOT11_PHY_TYPE.dot11_phy_type_vht: return "802.11ac";
case DOT11_PHY_TYPE.dot11_phy_type_dmg: return "802.11ad";
case DOT11_PHY_TYPE.dot11_phy_type_he: return "802.11ax";
case DOT11_PHY_TYPE.dot11_phy_type_eht: return "802.11be";
default: return null;
}
}
}
}
@@ -0,0 +1,142 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Native WLAN API interop (<c>wlanapi.dll</c>) used to read the current WiFi connection
/// (SSID, signal quality, PHY type, channel) without spawning <c>netsh</c>.
/// <para>
/// The WLAN API is language-neutral — unlike parsing <c>netsh wlan show interfaces</c>
/// output, whose labels follow the OS UI language and silently drop fields on
/// non-English images. On machines without the WLAN AutoConfig service (VMs, servers,
/// wired-only devices) <c>WlanOpenHandle</c>/<c>WlanEnumInterfaces</c> fail with a
/// non-zero error code; callers treat that as "no WiFi" (see <see cref="WifiInfoReader"/>).
/// </para>
/// </summary>
internal static class WlanNativeMethods
{
internal const uint ERROR_SUCCESS = 0;
/// <summary>Client version 2 = Vista and later (the only relevant range).</summary>
internal const uint WLAN_CLIENT_VERSION = 2;
internal enum WLAN_INTF_OPCODE : uint
{
wlan_intf_opcode_current_connection = 7,
wlan_intf_opcode_channel_number = 8,
}
internal enum WLAN_INTERFACE_STATE
{
wlan_interface_state_not_ready = 0,
wlan_interface_state_connected = 1,
wlan_interface_state_ad_hoc_network_formed = 2,
wlan_interface_state_disconnecting = 3,
wlan_interface_state_disconnected = 4,
wlan_interface_state_associating = 5,
wlan_interface_state_discovering = 6,
wlan_interface_state_authenticating = 7,
}
internal enum DOT11_PHY_TYPE : uint
{
dot11_phy_type_unknown = 0,
dot11_phy_type_fhss = 1,
dot11_phy_type_dsss = 2,
dot11_phy_type_irbaseband = 3,
dot11_phy_type_ofdm = 4, // 802.11a
dot11_phy_type_hrdsss = 5, // 802.11b
dot11_phy_type_erp = 6, // 802.11g
dot11_phy_type_ht = 7, // 802.11n
dot11_phy_type_vht = 8, // 802.11ac
dot11_phy_type_dmg = 9, // 802.11ad
dot11_phy_type_he = 10, // 802.11ax
dot11_phy_type_eht = 11, // 802.11be
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_INTERFACE_INFO
{
public Guid InterfaceGuid;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strInterfaceDescription;
public WLAN_INTERFACE_STATE isState;
}
[StructLayout(LayoutKind.Sequential)]
internal struct DOT11_SSID
{
public uint uSSIDLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] ucSSID;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_ASSOCIATION_ATTRIBUTES
{
public DOT11_SSID dot11Ssid;
public int dot11BssType;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
public byte[] dot11Bssid;
public DOT11_PHY_TYPE dot11PhyType;
public uint uDot11PhyIndex;
public uint wlanSignalQuality; // 0..100, same semantics as netsh "Signal"
public uint ulRxRate;
public uint ulTxRate;
}
[StructLayout(LayoutKind.Sequential)]
internal struct WLAN_SECURITY_ATTRIBUTES
{
[MarshalAs(UnmanagedType.Bool)]
public bool bSecurityEnabled;
[MarshalAs(UnmanagedType.Bool)]
public bool bOneXEnabled;
public int dot11AuthAlgorithm;
public int dot11CipherAlgorithm;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WLAN_CONNECTION_ATTRIBUTES
{
public WLAN_INTERFACE_STATE isState;
public int wlanConnectionMode;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string strProfileName;
public WLAN_ASSOCIATION_ATTRIBUTES wlanAssociationAttributes;
public WLAN_SECURITY_ATTRIBUTES wlanSecurityAttributes;
}
[DllImport("wlanapi.dll")]
internal static extern uint WlanOpenHandle(
uint dwClientVersion,
IntPtr pReserved,
out uint pdwNegotiatedVersion,
out IntPtr phClientHandle);
[DllImport("wlanapi.dll")]
internal static extern uint WlanCloseHandle(
IntPtr hClientHandle,
IntPtr pReserved);
[DllImport("wlanapi.dll")]
internal static extern uint WlanEnumInterfaces(
IntPtr hClientHandle,
IntPtr pReserved,
out IntPtr ppInterfaceList);
[DllImport("wlanapi.dll")]
internal static extern uint WlanQueryInterface(
IntPtr hClientHandle,
[In] ref Guid pInterfaceGuid,
WLAN_INTF_OPCODE OpCode,
IntPtr pReserved,
out uint pdwDataSize,
out IntPtr ppData,
IntPtr pWlanOpcodeValueType);
[DllImport("wlanapi.dll")]
internal static extern void WlanFreeMemory(IntPtr pMemory);
}
}
@@ -218,15 +218,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
/// True for a successful enrollment, false for a failed one (drives the OnFailure mode
/// check and sessioninfo.txt content). Pass true for WhiteGlove pre-provisioning (it
/// succeeded up to this point). Pass <b>null</b> when the session has no outcome yet
/// (on-demand server-requested collection mid-enrollment): the OnFailure gate does not
/// apply — there is no success to skip on — and sessioninfo.txt records "In Progress".
/// </param>
/// <param name="fileNameSuffix">
/// Optional suffix inserted before the .zip extension.
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
/// </param>
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool? enrollmentSucceeded, string fileNameSuffix = null)
{
try
{
@@ -244,13 +247,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
return null;
}
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
// skips a KNOWN success — an in-flight session has nothing to skip on.
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
// Build ZIP in memory
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
@@ -343,7 +348,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
// tests can assert archive contents without going through the upload path.
internal virtual byte[] BuildArchiveBytes(bool enrollmentSucceeded)
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
{
var tracker = new BudgetTracker(Budget);
using (var ms = new MemoryStream())
@@ -414,14 +419,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
{
var sb = new StringBuilder();
sb.AppendLine($"Session ID: {_configuration.SessionId}");
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
sb.AppendLine($"Device Name: {Environment.MachineName}");
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded == null ? "In Progress" : enrollmentSucceeded.Value ? "Succeeded" : "Failed")}");
// Hardware info via existing DeviceInfoProvider (WMI)
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
@@ -815,7 +815,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// Collects active network interface details (name, type, link speed, MAC) using
/// System.Net.NetworkInformation — instant, no process spawn.
/// If the active interface is WiFi, fires off a separate async task to collect
/// WiFi signal info via netsh (fire-and-forget, never blocks).
/// WiFi signal info via the native WLAN API (fire-and-forget, never blocks).
/// </summary>
private void CollectActiveNetworkInterfaceInfo()
{
@@ -858,7 +858,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// Fire-and-forget WiFi signal collection — separate event, never blocks
if (isWifi)
{
Task.Run(() => CollectWiFiSignalInfo());
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
Task.Run(() => CollectWiFiSignalInfo(nicGuid));
}
}
catch (Exception ex)
@@ -899,78 +900,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
/// <summary>
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
/// never blocks other collection. VMs without WiFi service simply get no event.
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
/// language-neutral, no process spawn) and emits a separate wifi_signal_info event.
/// Designed to run as fire-and-forget via Task.Run — never blocks other collection.
/// VMs without WiFi service simply get no event.
/// </summary>
private void CollectWiFiSignalInfo()
private void CollectWiFiSignalInfo(Guid? interfaceGuid)
{
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
_logger.Debug($"EnrollmentTracker: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
if (wifi == null)
return;
}
if (string.IsNullOrEmpty(output))
return;
var data = new Dictionary<string, object>();
if (wifi.Ssid != null)
data["wifiSsid"] = wifi.Ssid;
if (wifi.SignalPercent.HasValue)
data["wifiSignalPercent"] = wifi.SignalPercent.Value;
if (wifi.RadioType != null)
data["wifiRadioType"] = wifi.RadioType;
if (wifi.Channel.HasValue)
data["wifiChannel"] = wifi.Channel.Value;
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info";
if (wifi.SignalPercent.HasValue)
message += $", Signal: {wifi.SignalPercent.Value}%";
if (wifi.RadioType != null)
message += $" ({wifi.RadioType})";
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
data["wifiSsid"] = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var signal))
data["wifiSignalPercent"] = signal;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
data["wifiRadioType"] = value;
}
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value, out var channel))
data["wifiChannel"] = channel;
}
}
if (data.Count > 0)
{
var message = data.ContainsKey("wifiSsid")
? $"WiFi: {data["wifiSsid"]}"
: "WiFi signal info";
if (data.ContainsKey("wifiSignalPercent"))
message += $", Signal: {data["wifiSignalPercent"]}%";
if (data.ContainsKey("wifiRadioType"))
message += $" ({data["wifiRadioType"]})";
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data);
}
catch (Exception ex)
{
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Shared.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
@@ -25,14 +26,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
var filePath = rule.Target;
if (string.IsNullOrEmpty(filePath))
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, "empty target — nothing to parse");
return null;
}
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
if (filePath == null)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
"target contains %LOGGED_ON_USER_PROFILE% but no user is logged on — skipped this run");
return null; // Token present but no user logged on — skip silently
}
// Apply IME log path override if set. The override comes from the local
// --ime-log-path flag only (never from remote config), so the operator
@@ -66,6 +74,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
string.IsNullOrEmpty(patternStr))
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
"missing 'pattern' parameter — rule can never match");
return null;
}
@@ -93,6 +103,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
$"invalid regex pattern '{patternStr}' — rule can never match: {ex.Message}");
return null;
}
@@ -101,6 +113,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
if (resolvedPaths.Count == 0)
{
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"no files matched target {filePath}" + (HasWildcard(Path.GetFileName(filePath)) ? " (wildcard)" : "") + " — nothing to parse");
return null;
}
@@ -120,6 +134,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
return null;
}
// Per-match trace lines are capped per file per run — the per-file summary line
// still carries the full match count, this only bounds the per-match detail.
private const int MaxTracedMatchesPerFile = 10;
/// <summary>
/// Writes one debug-trace line per regex match: the line number, the matched text,
/// and every capture group that will land in the emitted event (same "0" exclusion
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
/// </summary>
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
int lineNumber, Match match, Regex pattern, int matchNumber)
{
if (matchNumber > MaxTracedMatchesPerFile)
{
if (matchNumber == MaxTracedMatchesPerFile + 1)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: further matches not traced individually (cap {MaxTracedMatchesPerFile}/file/run) — see the per-file summary for the total");
return;
}
var sb = new StringBuilder();
sb.Append(fileName).Append(": line ").Append(lineNumber)
.Append(" matched \"").Append(TruncateMessage(match.Value, 200)).Append('"');
var first = true;
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (!group.Success) continue;
sb.Append(first ? " — groups: " : ", ");
first = false;
sb.Append(groupName).Append("=\"").Append(TruncateMessage(group.Value, 100)).Append('"');
}
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser, sb.ToString());
}
private static bool HasWildcard(string fileNamePart)
=> !string.IsNullOrEmpty(fileNamePart) &&
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
@@ -144,7 +196,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
try
{
// Return matched files sorted by last write time (newest first), capped at 20
return Directory.GetFiles(directory, fileNamePart)
var allMatches = Directory.GetFiles(directory, fileNamePart);
if (allMatches.Length > 20)
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
return allMatches
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.Take(20)
.ToList();
@@ -152,6 +208,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
context.DebugLog(ruleId, GatherRuleDebugLog.StageError, $"wildcard expansion failed for {filePath}: {ex.Message}");
return new List<string>();
}
}
@@ -168,10 +225,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Nothing new to read
if (startPosition >= fileInfo.Length)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
return;
}
int matchCount = 0;
int linesRead = 0;
int parseFailCount = 0;
int timeoutCount = 0;
long endPosition = startPosition;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
@@ -195,6 +258,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
@@ -233,13 +297,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
{
parseFailCount++;
continue;
}
Match match;
try
@@ -248,6 +316,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
@@ -288,6 +357,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
}
@@ -301,10 +371,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
if (matchCount > 0)
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
// Per-file outcome — always written so a "matched 0" run is visible too.
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
if (linesRead == maxLines)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
}
catch (Exception ex)
{
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"failed reading {filePath}: {ex}");
}
}
@@ -113,7 +113,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Suppress the "not found" event when the rule only wants a positive signal.
if (emitOnlyIfExists)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
$"registry key/value not found: {path} — emitOnlyIfExists=true, empty result");
return new Dictionary<string, object>(); // empty → executor emits nothing
}
data["exists"] = false;
data["path"] = path;
@@ -22,13 +22,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public LogFilePositionTracker FilePositionTracker { get; }
public bool UnrestrictedMode { get; set; }
/// <summary>
/// Optional gather-rule debug trace writer (EnableGatherRuleDebugLog). Null when
/// tracing is disabled — use <see cref="DebugLog"/> for null-safe writes.
/// </summary>
public GatherRuleDebugLog DebugWriter { get; }
public GatherRuleContext(
AgentLogger logger,
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEventCollected,
string imeLogPathOverride,
LogFilePositionTracker filePositionTracker)
LogFilePositionTracker filePositionTracker,
GatherRuleDebugLog debugLog = null)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
@@ -36,8 +43,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
ImeLogPathOverride = imeLogPathOverride;
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
DebugWriter = debugLog;
}
/// <summary>
/// Null-safe write to the gather-rule debug trace. No-op when tracing is disabled.
/// </summary>
public void DebugLog(string ruleId, string stage, string message) => DebugWriter?.Write(ruleId, stage, message);
/// <summary>
/// Emits a security_warning event and returns an empty result dictionary.
/// Called when a gather rule targets a path/query not on the allowlist.
@@ -45,6 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
{
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageGuard,
$"{collectorType} target blocked by allowlist guard: {target} — security_warning emitted, rule produced no data");
var data = new Dictionary<string, object>
{
@@ -0,0 +1,116 @@
using System;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Append-only trace file for gather-rule evaluation, enabled per tenant via
/// EnableGatherRuleDebugLog (or --gather-debug-log). Explains every evaluation
/// decision — including the outcomes that produce no event — so customers can
/// diagnose rules that never show up in the timeline. Every write failure is
/// swallowed: tracing must never break gathering.
/// </summary>
public sealed class GatherRuleDebugLog
{
public const string StageConfig = "config";
public const string StagePhase = "phase";
public const string StageTrigger = "trigger";
public const string StageScope = "scope";
public const string StageExec = "exec";
public const string StageCollector = "collector";
public const string StageGuard = "guard";
public const string StageEmit = "emit";
public const string StageSuppress = "suppress";
public const string StageLogParser = "logparser";
public const string StageError = "error";
private const long DefaultMaxBytes = 10 * 1024 * 1024;
private readonly string _path;
private readonly long _maxBytes;
private readonly Action<string> _echo;
private readonly object _lock = new object();
private bool _headerWritten;
public GatherRuleDebugLog(string path, AgentLogger logger, Action<string> echo = null, long maxBytes = DefaultMaxBytes)
{
if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
_path = path;
_maxBytes = maxBytes;
_echo = echo;
logger?.Info($"GatherRuleDebugLog: enabled -> {path}");
}
/// <summary>
/// Writes one trace line: <c>{utc} | {ruleId} | {stage} | {message}</c>.
/// Newlines in <paramref name="message"/> are flattened except for the error
/// stage, where exception stack traces stay multi-line.
/// </summary>
public void Write(string ruleId, string stage, string message)
{
try
{
if (stage != StageError && message != null && message.IndexOf('\n') >= 0)
message = message.Replace("\r", "").Replace('\n', ' ');
var line = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | {ruleId ?? "-"} | {stage} | {message}";
lock (_lock)
{
EnsureFileReadyLocked();
File.AppendAllText(_path, line + Environment.NewLine);
}
_echo?.Invoke(line);
}
catch { }
}
/// <summary>
/// One-shot helper for callers that have no executor (e.g. the flag is on but the
/// backend delivered zero gather rules) — the customer's trace file should explain
/// that too instead of silently not existing.
/// </summary>
public static void WriteStandalone(string path, string message, AgentLogger logger)
{
if (string.IsNullOrEmpty(path)) return;
try
{
new GatherRuleDebugLog(path, logger).Write(null, StageConfig, message);
}
catch { }
}
// Lazy on first write: create the directory and emit a header line. Before every
// append, rotate when the file exceeds maxBytes — keeping exactly one .old
// generation (worst case ~2x maxBytes on disk).
private void EnsureFileReadyLocked()
{
if (!_headerWritten)
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
var info = new FileInfo(_path);
if (info.Exists && info.Length > _maxBytes)
{
var oldPath = Path.ChangeExtension(_path, null) + ".old.log";
if (File.Exists(oldPath))
File.Delete(oldPath);
File.Move(_path, oldPath);
File.AppendAllText(_path,
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- rotated (previous file exceeded {_maxBytes} bytes) --" + Environment.NewLine);
}
if (!_headerWritten)
{
File.AppendAllText(_path,
$"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} | - | {StageConfig} | -- gather rule debug log started --" + Environment.NewLine);
_headerWritten = true;
}
}
}
}
@@ -22,6 +22,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public class GatherRuleExecutor : IDisposable
{
private readonly AgentLogger _logger;
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
private readonly GatherRuleContext _context;
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
@@ -61,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, string imeLogPathOverride = null)
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
Action<string> debugEcho = null)
{
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
@@ -69,9 +71,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (logger == null) throw new ArgumentNullException(nameof(logger));
_logger = logger;
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
var filePositionTracker = new LogFilePositionTracker();
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
// Register all collector strategies
var collectorList = new IGatherRuleCollector[]
@@ -96,6 +99,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_collectors["command"] = _collectors["command_allowlisted"];
}
/// <summary>
/// Null-safe write to the gather-rule debug trace. Never call while holding
/// <see cref="_scopeLock"/> — capture the message under the lock, write after release.
/// </summary>
private void DebugLog(string ruleId, string stage, string message) => _debug?.Write(ruleId, stage, message);
/// <summary>
/// Updates the active rules and starts/stops execution accordingly
/// </summary>
@@ -111,11 +120,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_activeRules = rules.Where(r => r.Enabled).ToList();
// Registration summary for the debug trace: one line per delivered rule so the
// customer can see exactly what the agent received and how it will be triggered.
if (_debug != null)
{
foreach (var rule in rules)
{
if (!rule.Enabled)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig, "rule disabled — will never run");
continue;
}
var scope = rule.ActivePhases != null && rule.ActivePhases.Count > 0
? $"activePhases=[{string.Join(",", rule.ActivePhases)}]"
: !string.IsNullOrEmpty(rule.ActiveFromPhase)
? $"activeFromPhase={rule.ActiveFromPhase}"
: "unscoped";
DebugLog(rule.RuleId, GatherRuleDebugLog.StageConfig,
$"registered: trigger={rule.Trigger}" +
(string.IsNullOrEmpty(rule.TriggerPhase) ? "" : $" triggerPhase={rule.TriggerPhase}") +
(string.IsNullOrEmpty(rule.TriggerEventType) ? "" : $" triggerEventType={rule.TriggerEventType}") +
$", collector={rule.CollectorType}, target={rule.Target}, {scope}" +
$", emitMode={(string.IsNullOrEmpty(rule.EmitMode) ? "always" : rule.EmitMode)}" +
(rule.IntervalSeconds.HasValue ? $", interval={rule.IntervalSeconds}s" : ""));
if (rule.Trigger == "interval" && !rule.IntervalSeconds.HasValue)
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError,
"trigger=interval but intervalSeconds is missing — rule will NEVER run");
}
}
// Execute startup rules — track completion via CountdownEvent so callers can wait.
// Scoped startup rules that are not yet in scope are deferred: they run once from
// OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a
// rule runs either here or there, never both). Unscoped rules behave as before.
List<GatherRule> pendingStartup;
var deferredTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
// A config refresh may deliver new from-phase rules after their phase was
@@ -124,7 +165,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
pendingStartup = _activeRules
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
.Where(r => IsRuleInScopeLocked(r))
.Where(r =>
{
string reason;
var inScope = IsRuleInScopeLocked(r, out reason);
if (!inScope && deferredTraces != null)
deferredTraces.Add(new KeyValuePair<string, string>(r.RuleId,
$"startup rule deferred — {reason}; runs once when its scope activates"));
return inScope;
})
.ToList();
foreach (var rule in pendingStartup)
@@ -133,6 +182,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
if (deferredTraces != null)
{
foreach (var trace in deferredTraces)
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
}
if (pendingStartup.Count > 0)
{
_startupRulesLatch?.Dispose();
@@ -158,8 +213,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
var timer = new Timer(
_ =>
{
if (!IsRuleInScope(intervalRule))
string skipReason;
if (!IsRuleInScope(intervalRule, out skipReason))
{
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageScope, $"interval tick skipped: {skipReason}");
return;
}
ExecuteRule(intervalRule);
},
null,
@@ -168,6 +227,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
);
_intervalTimers[intervalRule.RuleId] = timer;
_logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s");
DebugLog(intervalRule.RuleId, GatherRuleDebugLog.StageTrigger,
$"interval timer scheduled every {intervalRule.IntervalSeconds}s (first run after one full interval — no immediate execution)");
}
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
@@ -187,9 +248,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
// phase that failed — that snapshot is the whole point at a failure boundary.
List<GatherRule> exitRules = null;
EnrollmentPhase previousPhase;
var exitTraces = _debug != null ? new List<KeyValuePair<string, string>>() : null;
lock (_scopeLock)
{
var previousPhase = _currentPhase;
previousPhase = _currentPhase;
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
{
var previousPhaseName = previousPhase.ToString();
@@ -204,12 +267,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Scope gate before the dedup add — an out-of-scope pass must not consume
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
if (!IsRuleInScopeLocked(rule)) continue;
string scopeReason;
if (!IsRuleInScopeLocked(rule, out scopeReason))
{
if (exitTraces != null)
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
$"phase_exit of {previousPhaseName} skipped: {scopeReason}"));
continue;
}
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
{
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
if (exitTraces != null)
exitTraces.Add(new KeyValuePair<string, string>(rule.RuleId,
$"phase_exit already executed for exit of {previousPhaseName} — once per (rule, phase)"));
continue;
}
@@ -219,6 +292,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
}
// Transition marker first, so every rule-level line below reads against it.
// Written outside _scopeLock like all trace lines.
if (previousPhase != newPhase)
DebugLog(null, GatherRuleDebugLog.StagePhase,
$"phase change: {previousPhase} -> {phaseName}" +
(previousPhase == EnrollmentPhase.Unknown
? " (first phase signal — scoped rules now evaluate against this phase)"
: ""));
if (exitTraces != null)
{
foreach (var trace in exitTraces)
DebugLog(trace.Key, GatherRuleDebugLog.StageScope, trace.Value);
}
if (exitRules != null)
{
foreach (var rule in exitRules)
@@ -252,6 +340,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
var deferredRule = rule;
_logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}");
DebugLog(deferredRule.RuleId, GatherRuleDebugLog.StageTrigger,
$"phase {phaseName} activated deferred startup rule");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule));
}
@@ -262,18 +352,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
// Scope gate BEFORE the dedup add — an out-of-scope pass must not consume
// the once-per-(rule, phase) execution slot.
if (!IsRuleInScope(rule))
string scopeReason;
if (!IsRuleInScope(rule, out scopeReason))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
$"phase_change to {phaseName} skipped: {scopeReason}");
continue;
}
// Deduplicate: only fire once per (ruleId, phase) combination
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
if (!_phaseRulesExecuted.Add(deduplicationKey))
{
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger,
$"phase_change already executed for phase {phaseName} — once per (rule, phase)");
continue;
}
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"phase_change to {phaseName} fired");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
@@ -288,10 +386,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
{
if (!IsRuleInScope(rule))
string scopeReason;
if (!IsRuleInScope(rule, out scopeReason))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageScope,
$"on_event {eventType} skipped: {scopeReason}");
continue;
}
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageTrigger, $"on_event {eventType} fired");
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
}
}
@@ -302,6 +406,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
try
{
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageExec, $"executing: collector={rule.CollectorType}, target={rule.Target}");
var collectorType = rule.CollectorType?.ToLower();
@@ -309,6 +414,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
{
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"unknown collector type '{rule.CollectorType}' — nothing emitted");
return;
}
@@ -316,7 +422,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// LogParser (and potentially others) emit events directly and return null
if (result == null)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
collectorType == "logparser"
? "logparser emits per-match events directly — see logparser lines above for the per-file outcome"
: "collector returned null — nothing emitted");
return;
}
if (result.Count == 0)
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageCollector,
"collector returned EMPTY result — nothing emitted (typical causes: target not found with emitOnlyIfExists, or guard block — see guard line if present)");
}
if (result.Count > 0)
{
@@ -325,8 +443,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// empty result never reaches this point, so an emitOnlyIfExists miss neither
// emits nor updates the hash — composing to "one event on appearance, then
// only on change".
if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result))
return;
if (IsOnChangeMode(rule))
{
string hashPrefix;
int suppressedStreak;
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
return;
}
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
$"result changed (hash {hashPrefix})" + (suppressedStreak > 0 ? $" after {suppressedStreak} suppressed polls" : ""));
}
result["ruleId"] = rule.RuleId;
result["ruleTitle"] = rule.Title;
@@ -357,11 +486,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
Message = $"Gather: {rule.Title}",
Data = result
});
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
}
}
catch (Exception ex)
{
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
// Full exception incl. stack trace only in the debug trace — the agent log stays terse.
DebugLog(rule.RuleId, GatherRuleDebugLog.StageError, $"rule execution failed: {ex}");
}
}
@@ -375,15 +508,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Internal so the scope matrix is testable synchronously (no timer/ThreadPool races).
internal bool IsRuleInScope(GatherRule rule)
{
string reason;
return IsRuleInScope(rule, out reason);
}
/// <summary>
/// Scope gate with a human-readable skip reason for the debug trace. The reason is
/// captured under the lock but must be WRITTEN outside it (never trace under _scopeLock).
/// </summary>
internal bool IsRuleInScope(GatherRule rule, out string reason)
{
lock (_scopeLock)
{
return IsRuleInScopeLocked(rule);
return IsRuleInScopeLocked(rule, out reason);
}
}
private bool IsRuleInScopeLocked(GatherRule rule)
{
string reason;
return IsRuleInScopeLocked(rule, out reason);
}
private bool IsRuleInScopeLocked(GatherRule rule, out string reason)
{
reason = null;
if (!HasPhaseScope(rule))
return true; // unscoped — legacy behavior
@@ -400,7 +551,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// No phase signal yet (or scope tokens that never match, e.g. "Unknown"):
// scoped rules stay inactive.
if (_currentPhase == EnrollmentPhase.Unknown)
{
reason = "currentPhase=Unknown — scoped rules stay inactive until the first phase signal";
return false;
}
var phaseName = _currentPhase.ToString();
foreach (var phase in rule.ActivePhases)
@@ -408,10 +562,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase))
return true;
}
reason = $"out of scope (currentPhase={phaseName}, activePhases=[{string.Join(",", rule.ActivePhases)}])";
return false;
}
return _fromPhaseLatched.Contains(rule.RuleId);
if (_fromPhaseLatched.Contains(rule.RuleId))
return true;
reason = $"activeFromPhase={rule.ActiveFromPhase} not latched yet (currentPhase={_currentPhase})";
return false;
}
/// <summary>
@@ -457,8 +616,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
/// Internal so the state machine is testable synchronously (no timer/ThreadPool races).
/// </summary>
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result)
{
string hashPrefix;
int suppressedStreak;
return ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak);
}
/// <summary>
/// Overload exposing the hash prefix and suppression streak for the debug trace:
/// on suppress, <paramref name="suppressedStreak"/> is the updated streak count;
/// on emit, it is the streak that just ended (0 when none).
/// </summary>
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
out string hashPrefix, out int suppressedStreak)
{
var hash = ComputeCanonicalHash(result);
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
suppressedStreak = 0;
bool emit;
lock (_scopeLock)
{
@@ -469,6 +643,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak)
? (streak.Count + 1, streak.SinceUtc)
: (1, DateTime.UtcNow);
suppressedStreak = _suppressed[rule.RuleId].Count;
emit = false;
}
else
@@ -479,6 +654,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
result["suppressedPolls"] = streak.Count;
result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture);
suppressedStreak = streak.Count;
}
_suppressed.Remove(rule.RuleId);
emit = true;
@@ -8,8 +8,8 @@ using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -37,7 +37,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
private const int DebounceDelayMs = 3000;
private const int ConnectivityCheckDelayMs = 5000;
private const int ConnectivityCheckTimeoutMs = 8000;
private const int WifiCollectTimeoutMs = 3000;
private const string Source = "NetworkChangeDetector";
/// <summary>
@@ -374,7 +373,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Collect WiFi SSID synchronously (needed for before/after comparison)
if (isWifi)
{
var wifiInfo = CollectWiFiInfo();
Guid? nicGuid = Guid.TryParse(activeNic.Id, out var parsedGuid) ? parsedGuid : (Guid?)null;
var wifiInfo = CollectWiFiInfo(nicGuid);
snapshot.WifiSsid = wifiInfo.ssid;
snapshot.WifiSignalPercent = wifiInfo.signal;
snapshot.WifiRadioType = wifiInfo.radioType;
@@ -390,73 +390,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
/// Synchronous with timeout — needed before emitting the event.
/// Collects WiFi SSID, signal, and radio type via the native WLAN API
/// (<see cref="WifiInfoReader"/> — language-neutral, no process spawn).
/// Synchronous — needed before emitting the event.
/// </summary>
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
private (string ssid, int? signal, string radioType) CollectWiFiInfo(Guid? interfaceGuid)
{
string ssid = null;
int? signal = null;
string radioType = null;
try
{
var psi = new ProcessStartInfo
var wifi = WifiInfoReader.TryGetCurrentConnection(interfaceGuid, out var wifiDiag);
if (wifi == null)
{
FileName = SystemPaths.Netsh,
Arguments = "wlan show interfaces",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
string output;
using (var process = Process.Start(psi))
{
output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(WifiCollectTimeoutMs))
{
try { process.Kill(); } catch { }
_logger.Debug("NetworkChangeDetector: netsh timed out");
return (null, null, null);
}
_logger.Debug($"NetworkChangeDetector: native WLAN API returned no WiFi info ({wifiDiag ?? "no connected WLAN interface"}) — trying netsh fallback");
wifi = NetshWifiFallback.TryRead();
}
if (string.IsNullOrEmpty(output))
return (null, null, null);
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = line.Trim();
var colonIndex = trimmed.IndexOf(':');
if (colonIndex < 0) continue;
var key = trimmed.Substring(0, colonIndex).Trim();
var value = trimmed.Substring(colonIndex + 1).Trim();
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
{
ssid = value;
}
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(value.TrimEnd('%'), out var sig))
signal = sig;
}
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
{
radioType = value;
}
}
if (wifi != null)
return (wifi.Ssid, wifi.SignalPercent, wifi.RadioType);
}
catch (Exception ex)
{
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
}
return (ssid, signal, radioType);
return (null, null, null);
}
// -------------------------------------------------------------------
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private DateTime? _lastReportTime;
private int _reportCount;
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
private readonly bool _isCloudPc;
public DistressReporter(
string baseUrl,
string tenantId,
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
string serialNumber,
string agentVersion,
AgentLogger logger,
X509Certificate2 clientCertificate = null)
X509Certificate2 clientCertificate = null,
bool isCloudPc = false)
{
_tenantId = tenantId;
_manufacturer = manufacturer;
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
_serialNumber = serialNumber;
_agentVersion = agentVersion;
_logger = logger;
_isCloudPc = isCloudPc;
// Capture cert context. Each field is independently nullable so the backend can tell
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow,
IsCloudPc = _isCloudPc,
CertSourceState = _capturedCertState,
CertThumbprint = _capturedCertThumbprint,
CertSubject = Truncate(_capturedCertSubject, 96),
@@ -97,7 +97,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock,
Transport.Telemetry.ITelemetrySpool? telemetrySpool)
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
@@ -471,8 +472,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (officeHost != null) hosts.Add(officeHost);
// M4.6.δ — Gather-rules runtime executor. Runs "startup" rules once the agent is up
// and "interval" rules on their timers; "phase_change" / "on_event" rules are driven
// by the host's SignalIngress.SignalPosted subscription (MON-A1).
// and "interval" rules on their timers; "phase_change" / "phase_exit" / "on_event"
// rules are driven by the post-reduce TimelineEventStream (emitted timeline events),
// NOT the raw SignalPosted stream — the raw phase can run minutes ahead of the
// engine-declared one (RealmJoin gate, session 32312a32). See TimelineEventStream doc.
if (_remoteConfig.GatherRules != null && _remoteConfig.GatherRules.Count > 0)
{
hosts.Add(new GatherRuleExecutorHost(
@@ -483,7 +486,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger: logger,
rules: _remoteConfig.GatherRules,
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
unrestrictedMode: _agentConfig.UnrestrictedMode));
unrestrictedMode: _agentConfig.UnrestrictedMode,
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
timelineEvents: timelineEvents));
}
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
{
// Debug trace is on but the backend delivered zero gather rules — the trace
// file must say so instead of silently not existing.
Monitoring.Telemetry.Gather.GatherRuleDebugLog.WriteStandalone(
_agentConfig.GatherRuleDebugLogPath,
"no gather rules delivered by backend — nothing to execute", logger);
}
return new CollectorSurfaces(hosts, imeLogHost, espAndHelloHost, aadJoinHost);
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private TelemetrySignalEmitter? _signalEmitter;
private TelemetryTransitionEmitter? _transitionEmitter;
private EventTimelineEmitter? _timelineEmitter;
private TimelineEventStream? _timelineEvents;
private BackPressureEventObserver? _backPressureObserver;
private IDeadlineScheduler? _scheduler;
private ClassifierRegistry? _classifierRegistry;
@@ -413,7 +414,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_transport, _eventSequenceCounter, _sessionId, _tenantId, _traceEventsEnabled);
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
// Post-reduce timeline observer: EventTimelineEmitter publishes every emitted event's
// (eventType, phase) here. Fed to the collector-host factory at step 14 so gather-rule
// phase_change/on_event triggers key off the ENGINE-REDUCED timeline (RealmJoin gate
// et al. respected) instead of raw pre-reduce signals — see TimelineEventStream doc.
_timelineEvents = new TimelineEventStream();
_timelineEmitter = new EventTimelineEmitter(eventEmitter, _timelineEvents);
_backPressureObserver = new BackPressureEventObserver(eventEmitter, _clock);
// 5) Deadlines + Classifiers.
@@ -653,7 +659,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
var surfaces = _componentFactory.CreateCollectorHosts(
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
ingress: _ingress, clock: _clock, telemetrySpool: _spool);
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
timelineEvents: _timelineEvents);
CollectorSurfaces = surfaces;
_collectorHosts = surfaces.Hosts;
foreach (var host in _collectorHosts)
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
getPackageStates: () => imeHost.PackageStates,
onDoTelemetryReceived: pkg =>
{
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
// null source timestamp: poll-driven completion, no log line backs it —
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
},
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
@@ -4,7 +4,6 @@ using System.Collections.Generic;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
@@ -19,17 +18,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly bool _unrestrictedMode;
private int _disposed;
// MON-A1: drive phase_change / on_event gather triggers from the central signal stream.
// V1 fired these from MonitoringService on every EnrollmentEvent (OnPhaseChanged(evt.Phase)
// on phase change + OnEvent(evt.EventType) per event); the V2 host previously only called
// UpdateRules, so 5 of 10 shipped rules (incl. the only enabled one, dsregcmd at
// FinalizingSetup) silently never fired. In V2 every event is an InformationalEvent signal
// carrying eventType + (optional) phase in its payload. Null on test fakes / non-SignalIngress
// sinks — then signal-triggers degrade off (startup + interval rules still run).
private readonly SignalIngress? _observableIngress;
private Action<DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
// MON-A1 (revised): drive phase_change / phase_exit / on_event gather triggers from the
// POST-REDUCE emitted timeline (TimelineEventStream), not from the raw signal stream.
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
// with the RAW collector payload — before the reducer has applied gates like the
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
// built to read. The raw feed also never carried engine-emitted event types at all, so
// on_event rules on enrollment_complete (documented) could never fire. The emitted-event
// feed restores V1 semantics and matches what the UI timeline shows ("collect once when
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
// then phase/event triggers degrade off (startup + interval rules still run).
private readonly TimelineEventStream? _timelineEvents;
private Action<string, EnrollmentPhase>? _timelineHandler;
private readonly object _sync = new object();
private string? _lastPhaseName;
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
public GatherRuleExecutorHost(
string sessionId,
@@ -39,7 +45,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
AgentLogger logger,
List<GatherRule> rules,
string? imeLogPathOverride,
bool unrestrictedMode = false)
bool unrestrictedMode = false,
string? gatherDebugLogPath = null,
TimelineEventStream? timelineEvents = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -55,10 +63,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// session mode we wrap post.Emit so every session-mode gather event still
// flows through the InformationalEvent ingress pipe before hitting the
// telemetry spool — Rail-A semantics for ordering / replay determinism.
var post = new InformationalEventPost(ingress, clock);
var post = new InformationalEventPost(ingress, clock, logger);
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride);
_observableIngress = ingress as SignalIngress;
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
debugLogPath: gatherDebugLogPath);
_timelineEvents = timelineEvents;
}
public void Start()
@@ -69,46 +78,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_executor.UnrestrictedMode = _unrestrictedMode;
_executor.UpdateRules(_rules);
// MON-A1: observe the central signal stream so phase_change / on_event rules fire.
if (_observableIngress != null && _signalPostedHandler == null)
// MON-A1 (revised): observe the emitted timeline so phase/event triggers fire in
// step with what the timeline actually shows.
if (_timelineEvents != null && _timelineHandler == null)
{
_signalPostedHandler = OnSignalPosted;
_observableIngress.SignalPosted += _signalPostedHandler;
_timelineHandler = OnTimelineEventEmitted;
_timelineEvents.EventEmitted += _timelineHandler;
}
_logger.Info(
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, signalTriggers={(_observableIngress != null)}.");
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, timelineTriggers={(_timelineEvents != null)}.");
}
/// <summary>
/// Translates posted signals into the executor's phase_change / on_event triggers (MON-A1).
/// Phase: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when
/// the observed phase changes and parses to an <see cref="EnrollmentPhase"/> (raw collector
/// phase strings that aren't enum names are ignored — they carry no gather-rule meaning).
/// Event: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every
/// InformationalEvent's eventType. The executor dispatches rule execution on the ThreadPool,
/// so this stays off the signal-posting hot path; it also dedups phase rules per (rule, phase).
/// Translates emitted timeline events into the executor's triggers. Phase: fire
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when a
/// phase-declaration event (Phase != Unknown) moves the timeline to a new phase — this
/// is by construction the engine-reduced phase, so deferred transitions (RealmJoin gate)
/// fire the rules exactly when the timeline shows them. Event: fire
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every emitted
/// event's type — including engine-emitted types (enrollment_complete, phase_transition,
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
/// effect path; it also dedups phase rules per (rule, phase).
/// </summary>
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
{
if (payload == null) return;
try
{
lock (_sync)
{
if (payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
&& !string.IsNullOrEmpty(phaseName)
&& !string.Equals(phaseName, _lastPhaseName, StringComparison.OrdinalIgnoreCase)
&& Enum.TryParse<EnrollmentPhase>(phaseName, ignoreCase: true, out var phase))
if (phase != EnrollmentPhase.Unknown && phase != _lastPhase)
{
_lastPhaseName = phaseName;
_lastPhase = phase;
_executor.OnPhaseChanged(phase);
}
if (kind == DecisionSignalKind.InformationalEvent
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
&& !string.IsNullOrEmpty(eventType))
if (!string.IsNullOrEmpty(eventType))
{
_executor.OnEvent(eventType);
}
@@ -116,17 +122,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
catch (Exception ex)
{
_logger.Verbose($"GatherRuleExecutorHost: signal-trigger dispatch failed: {ex.Message}");
_logger.Verbose($"GatherRuleExecutorHost: timeline-trigger dispatch failed: {ex.Message}");
}
}
public void Stop()
{
if (_observableIngress != null && _signalPostedHandler != null)
if (_timelineEvents != null && _timelineHandler != null)
{
try { _observableIngress.SignalPosted -= _signalPostedHandler; }
try { _timelineEvents.EventEmitted -= _timelineHandler; }
catch { /* best-effort unsubscribe during shutdown */ }
_signalPostedHandler = null;
_timelineHandler = null;
}
// GatherRuleExecutor is IDisposable; no explicit Stop beyond unsubscribe. Rely on Dispose.
}
@@ -39,6 +39,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <c>spool.pendingItemCount</c> / <c>spool.fileSizeBytes</c>. Nullable — fakes and
/// spool-less configurations pass null and the metrics fields are simply absent.
/// </para>
/// <para>
/// <paramref name="timelineEvents"/> flows into the <c>GatherRuleExecutorHost</c> so
/// <c>phase_change</c> / <c>phase_exit</c> / <c>on_event</c> gather triggers key off the
/// ENGINE-REDUCED emitted timeline (post-reduce, RealmJoin gate respected) instead of raw
/// pre-reduce signals — see <see cref="TimelineEventStream"/>. Nullable — fakes pass null
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
/// </para>
/// </summary>
CollectorSurfaces CreateCollectorHosts(
string sessionId,
@@ -47,6 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
ISignalIngressSink ingress,
IClock clock,
Transport.Telemetry.ITelemetrySpool? telemetrySpool);
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
TimelineEventStream? timelineEvents = null);
}
}
@@ -0,0 +1,55 @@
#nullable enable
using System;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Post-reduce observer surface for emitted timeline events: <see cref="Telemetry.Events.EventTimelineEmitter"/>
/// publishes every <see cref="EnrollmentEvent"/> it emits as an <c>(eventType, phase)</c> pair,
/// AFTER the decision engine has reduced the originating signal and its effects have been
/// executed.
/// <para>
/// This is deliberately different from <see cref="SignalIngress.SignalPosted"/>, which fires
/// at enqueue time on the producer thread and therefore only ever carries the RAW collector
/// payload — before the reducer has applied gates like the RealmJoin completion gate that can
/// defer or suppress a phase transition. Consumers that must agree with what the timeline
/// actually shows (the gather-rule phase/event triggers) subscribe here, not to the raw
/// signal stream. See session 32312a32: a <c>phase_change</c> gather rule on FinalizingSetup
/// fired at the raw <c>EspPhaseChanged(FinalizingSetup)</c> signal (ESP exit), 7 minutes
/// before the engine — deferred behind the RealmJoin gate — actually declared
/// <c>phase_transition(FinalizingSetup)</c> on the timeline.
/// </para>
/// <para>
/// <b>Threading</b>: <see cref="Publish"/> runs on the ingress worker thread (the effect
/// execution path). Subscribers must stay fast and must not block; subscriber exceptions are
/// swallowed per handler so the emit path can never throw (an exception here would surface
/// as an EffectRunner transient retry and duplicate the emitted event).
/// </para>
/// </summary>
public sealed class TimelineEventStream
{
/// <summary>
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
/// for the vast majority of events — only phase-declaration events carry a real phase,
/// which is exactly the set the UI timeline groups on).
/// </summary>
public event Action<string, EnrollmentPhase>? EventEmitted;
internal void Publish(string eventType, EnrollmentPhase phase)
{
var handler = EventEmitted;
if (handler == null) return;
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
{
try { subscriber(eventType, phase); }
catch
{
// Subscriber isolation — see class doc: the emit path must never throw.
}
}
}
}
}
@@ -0,0 +1,77 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
/// to any Hyper-V VM):
/// <list type="bullet">
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
/// so the agent needs no System.ServiceProcess dependency.</item>
/// </list>
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
/// physical clients may carry Windows365 policy state; the MCMD agent family also
/// served other managed-desktop offerings).
/// <para>
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
/// validation trusts exclusively the cert-CN-bound Graph lookup
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
/// </para>
/// </summary>
public static class CloudPcDetector
{
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
public static bool DetectIsCloudPc()
{
try
{
return ResolveIsCloudPc(SubKeyExists);
}
catch
{
return false;
}
}
/// <summary>
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
/// marker-AND semantics can be exercised without a real registry (same seam contract
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
/// whether it exists; a throwing probe degrades to <c>false</c>.
/// </summary>
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
{
if (subKeyExists == null) return false;
try
{
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
}
catch
{
return false;
}
}
private static bool SubKeyExists(string subKeyPath)
{
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
{
return key != null;
}
}
}
}
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
// IsSelfDeployingProfile is the additive classification signal.
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
private readonly Action<string>? _prevOnPatternMatched;
private readonly Action<string>? _prevOnImeAgentVersion;
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
private readonly Action<string> _ourOnPatternMatched;
private readonly Action<string> _ourOnImeAgentVersion;
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitImeAgentVersion(version);
}
private void OnDoTelemetryReceived(AppPackageState app)
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
{
_prevOnDoTelemetryReceived?.Invoke(app);
EmitDoTelemetry(app);
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
EmitDoTelemetry(app, sourceTimestampUtc);
}
private void OnScriptCompleted(ScriptExecutionState script)
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitAppState(app, oldState, newState);
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
EmitDoTelemetry(app, sourceTimestampUtc);
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
}
private void EmitDoTelemetry(AppPackageState app)
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
{
if (app == null || string.IsNullOrEmpty(app.Id)) return;
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
// Two trigger paths with different time grounding (session df1fcf47: a single
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
// session StartedAt back 7h and inflated the reported duration):
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
// event time — resolve/clamp it and apply historic-replay suppression.
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
DateTime now;
bool derivedFromClock;
DateTime? rawSourceTs;
if (sourceTimestampUtc.HasValue)
{
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
// Historic replay — a week-old DO summary is not this enrollment's download.
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
return;
// Historic replay — a week-old DO summary is not this enrollment's download.
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
return;
}
else
{
now = _clock.UtcNow;
derivedFromClock = true;
rawSourceTs = null;
}
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
@@ -81,10 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
internal const string ReasonParamKey = "reason";
private readonly TelemetryEventEmitter _emitter;
private readonly TimelineEventStream? _timelineEvents;
public EventTimelineEmitter(TelemetryEventEmitter emitter)
public EventTimelineEmitter(TelemetryEventEmitter emitter, TimelineEventStream? timelineEvents = null)
{
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_timelineEvents = timelineEvents;
}
public void Emit(
@@ -142,6 +144,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
};
_emitter.Emit(evt);
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
// Publish never throws (subscriber isolation inside the stream).
_timelineEvents?.Publish(evt.EventType, evt.Phase);
}
/// <summary>
@@ -39,8 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
/// <item>WhiteGlove Part-1 path: stop peripheral collectors first (so their stop-time
/// stragglers — e.g. <c>network_bandwidth_estimate</c> — sequence BEFORE the marker),
/// then emit <c>whiteglove_part1_complete</c>, drain spool, and write
/// <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// upload the diagnostics package as a pre-provisioning intermediate (suffix
/// <c>preprov</c>, same mode gating as the terminal path — the seal counts as a
/// success, so <c>OnFailure</c> skips), then emit <c>whiteglove_part1_complete</c>,
/// drain spool, and write <c>whiteglove.complete</c> marker via
/// <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// so Part-2 resume is detected on the next boot.</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
/// </list>
@@ -241,6 +244,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// the full Stop() later is a no-op for the hosts.
StopPeripheralCollectorsBestEffort();
// V1 parity (restored 2026-07-28) — upload the diagnostics package as a
// pre-provisioning intermediate. Between reseal and user OOBE the device
// may sit boxed for weeks, and if Part 2 never uploads (never runs, or
// OnFailure + success) the Part-1 forensics would be lost to self-destruct.
// No dedicated flag: the existing mode semantics apply — Always uploads,
// OnFailure skips (the seal is a success; a Part-1 failure terminates via
// the regular Failed path below, which uploads), Off does nothing. Must run
// BEFORE the whiteglove_part1_complete emit so diagnostics_* events keep a
// lower sequence than the Part-1/Part-2 boundary marker. Best-effort: a
// reseal-reboot racing the upload just loses the intermediate package.
RunUploadDiagnosticsWithEvents(args, suffixOverride: "preprov");
EmitEventSafe(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
@@ -777,7 +792,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
StringComparison.Ordinal);
}
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
/// <param name="args">Termination payload — outcome drives the OnFailure gate.</param>
/// <param name="suffixOverride">
/// Blob-name suffix override. Null (terminal path) derives "success"/"failure" from
/// the outcome; the WhiteGlove Part-1 seal passes "preprov" so the intermediate
/// package is distinguishable from the later Part-2 terminal upload.
/// </param>
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
@@ -813,7 +834,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
DiagnosticsUploadResult result = null;
try
{
var suffix = enrollmentSucceeded ? "success" : "failure";
var suffix = suffixOverride ?? (enrollmentSucceeded ? "success" : "failure");
result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
}
catch (Exception ex)
@@ -221,8 +221,18 @@ namespace AutopilotMonitor.Agent.V2
},
});
// Step 4 — run the executor and wait.
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride))
// Step 4 — run the executor and wait. Diagnostic mode always writes the
// gather-rule debug trace (CLI override or default path) and mirrors it
// to the console in --console mode.
var gatherDebugLogPath = !string.IsNullOrEmpty(config.GatherRuleDebugLogPath)
? config.GatherRuleDebugLogPath
: Environment.ExpandEnvironmentVariables(Constants.GatherRuleDebugLogPath);
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
debugLogPath: gatherDebugLogPath,
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
{
// Diagnostic mode has no enrollment phase context — scoped rules
// (activePhases / activeFromPhase) execute unconditionally here.
@@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -303,6 +303,7 @@ namespace AutopilotMonitor.Agent.V2
var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl;
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
var gatherDebugLogPath = GetArgValue(args, "--gather-debug-log");
// Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
// + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
@@ -373,6 +374,7 @@ namespace AutopilotMonitor.Agent.V2
EnableGeoLocation = !disableGeoLocation,
ImeLogPathOverride = imeLogPathOverride,
ImeMatchLogPath = imeMatchLogPath,
GatherRuleDebugLogPath = gatherDebugLogPath,
KeepLogFile = keepLogFile,
SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
@@ -485,6 +487,7 @@ namespace AutopilotMonitor.Agent.V2
Console.Out.WriteLine(" --api-url <URL> Override backend API base URL (alias: --backend-api)");
Console.Out.WriteLine(" --ime-log-path <PATH> Override IME logs directory");
Console.Out.WriteLine(" --ime-match-log <PATH> Write matched IME log lines to file (debug)");
Console.Out.WriteLine(" --gather-debug-log <PATH> Write gather-rule evaluation trace to file (debug)");
Console.Out.WriteLine();
Console.Out.WriteLine("Dev / Test:");
Console.Out.WriteLine(" --replay-log-dir <PATH> Replay IME logs from this directory (simulation mode)");
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
serialNumber: hardware.SerialNumber,
agentVersion: agentVersion,
logger: logger,
clientCertificate: clientCertificate);
clientCertificate: clientCertificate,
isCloudPc: CloudPcDetector.DetectIsCloudPc());
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["enrollmentType"] = enrollmentType,
["isHybridJoin"] = isHybridJoin ? "true" : "false",
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
["isCloudPc"] = isCloudPc ? "true" : "false",
};
var evidence = new Evidence(
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
evidence: evidence,
payload: payload);
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
}
catch (Exception ex)
{
@@ -46,8 +46,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
},
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
// sessioninfo.txt honest ("In Progress") and passes the OnFailure gate by
// semantics (there is no success to skip on) instead of the former
// enrollmentSucceeded:false workaround that stamped in-flight packages "Failed".
uploadDiagnosticsAsync: async (suffix) =>
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: suffix),
onTerminateRequested: action => OnTerminateRequested(
action, agentConfig, orchestrator, terminationHandler, shutdown, shutdownComplete, logger),
post: post);
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
/// values set-once (mirror of the registry self-deploying fact), the profile reason
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
/// arrival requirement, so no behavioural gate is added — the observation is the
/// engine-side context that a session starting at Account Setup is the expected shape.
/// </summary>
public sealed class CloudPcObservationTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
payload: payload);
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
})).NewState;
[Fact]
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-1", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "true");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.True(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
Assert.Equal(
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
state.ScenarioProfile.Reason);
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
Assert.Equal(SessionStage.SessionStarted, state.Stage);
}
[Fact]
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-2", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "false");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.False(state.ScenarioObservations.CloudPc!.Value);
// Set-once: a later contradicting re-read must not overwrite the first sighting
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
state = ReduceFacts(engine, state, 2, "true");
Assert.False(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
}
[Fact]
public void Census_foldsPositiveMarker_only()
{
var engine = new DecisionEngine();
var initial = engine.Reduce(
DecisionState.CreateInitial("cpc-3", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
var census = DecisionStateSignalCensus.Build(cloudPcState);
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
var physicalState = ReduceFacts(engine, initial, 1, "false");
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
}
}
}
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
.Build();
var json = StateSerializer.Serialize(state);
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
Assert.False(o.RegistrySelfDeployingProfile!.Value);
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
Assert.True(o.CloudPc!.Value);
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
Assert.Null(o.WhiteGloveSealingPatternSeen);
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
// ================================================================== helpers
// ------------------------------------------------ isCloudPc annotation (W365)
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
// profile records the "no Device-ESP phase expected" expectation as the reason.
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
// and EspConfig keeps the CONFIGURED FirstSync semantics.
[Fact]
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
{
Dictionary<string, string> Facts() => new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
};
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, first);
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
// The repeat must be a full no-op: the Cloud PC annotation already owns the
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
// from re-claiming it (which would bump EvidenceOrdinal every post).
Assert.Same(p1, p2);
Assert.Equal(0, p2.EvidenceOrdinal);
}
[Fact]
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = "false",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
{
// Defensive combination (not expected in the field — Cloud PCs are never
// Autopilot-registered): the deterministic self-deploying Mode seed must
// survive; only the reason slot goes to the more specific annotation.
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
}
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
@@ -0,0 +1,34 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value + round-trip tests for <see cref="AdminConfiguration.AutoApproveNewTenants"/>.
/// The flag gates the tenant auto-approve queue worker — defaulting to false keeps new tenant
/// signups on manual Global Admin approval until the operator deliberately opts in.
/// </summary>
public class AdminConfigurationAutoApproveNewTenantsTests
{
[Fact]
public void AutoApproveNewTenants_defaults_to_false_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.False(cfg.AutoApproveNewTenants);
}
[Fact]
public void AutoApproveNewTenants_defaults_to_false_on_CreateDefault()
{
// CreateDefault() is called when no admin-config row exists yet — if it somehow
// enabled auto-approve, tenants would activate without any operator opt-in.
var cfg = AdminConfiguration.CreateDefault();
Assert.False(cfg.AutoApproveNewTenants);
}
[Fact]
public void AutoApproveNewTenants_true_persists_on_the_config()
{
var cfg = new AdminConfiguration { AutoApproveNewTenants = true };
Assert.True(cfg.AutoApproveNewTenants);
}
}
@@ -51,5 +51,7 @@ public class AdminConfigurationRateLimitSyncTests
// ...but NO tenant configuration is enumerated or mutated (sync removed).
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
}
@@ -0,0 +1,64 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value + fail-closed tests for <see cref="AdminConfiguration.SelfServiceAppHomingEnabled"/>.
/// The flag is the kill switch of the dual app-reg self-service homing flip — defaulting to false
/// keeps the consent funnel and auto-flip off until the operator deliberately opts in, and a
/// storage error must read as "disabled" (kill switch wins over a blip).
/// </summary>
public class AdminConfigurationSelfServiceAppHomingTests
{
[Fact]
public void SelfServiceAppHoming_defaults_to_false_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.False(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public void SelfServiceAppHoming_defaults_to_false_on_CreateDefault()
{
var cfg = AdminConfiguration.CreateDefault();
Assert.False(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public void SelfServiceAppHoming_true_persists_on_the_config()
{
var cfg = new AdminConfiguration { SelfServiceAppHomingEnabled = true };
Assert.True(cfg.SelfServiceAppHomingEnabled);
}
[Fact]
public async Task Reader_returns_true_when_flag_enabled()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { SelfServiceAppHomingEnabled = true });
var svc = new AdminConfigurationService(
repo.Object, NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
Assert.True(await svc.IsSelfServiceAppHomingEnabledAsync());
}
[Fact]
public async Task Reader_fails_closed_on_repository_error()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = new AdminConfigurationService(
repo.Object, NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
Assert.False(await svc.IsSelfServiceAppHomingEnabledAsync());
}
}
@@ -189,7 +189,7 @@ public class AnalyzeRuleNotifyTests
var alert = NotificationAlertBuilder.BuildRuleFiredAlert(
result, "DESKTOP-CONTOSO1", "SN-1234",
"https://portal.autopilotmonitor.com/sessions/abc");
"https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Equal("analyze_rule_fired", alert.EventType);
Assert.Contains("Proxy blocks enrollment endpoints", alert.Title);
@@ -197,7 +197,7 @@ public class AnalyzeRuleNotifyTests
Assert.Contains(alert.Facts, f => f.Name == "Serial" && f.Value == "SN-1234");
Assert.Contains(alert.Facts, f => f.Name == "Rule" && f.Value.Contains("ANALYZE-NET-001"));
Assert.Contains(alert.Facts, f => f.Name == "Confidence" && f.Value == "85%");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions/abc");
Assert.Contains(alert.Actions, a => a.Type == "openUrl" && a.Url == "https://portal.autopilotmonitor.com/sessions?id=abc");
Assert.Contains(alert.Sections, s => s.Text.Contains("could not reach"));
}
@@ -0,0 +1,135 @@
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Authorization/validation matrix for <see cref="AppHomingService.EvaluateManualFlip"/> — the
/// pure decision behind <c>POST config/{tenantId}/app-homing</c>. Contract: tenant admins may
/// only flip TO primary, without force, with the self-service flag on, and only after a
/// successful consent probe; Global Admins may flip both directions and force past the probe.
/// </summary>
public class AppHomingDecisionTests
{
private static readonly AppHomingProbeResult ProbeOk = new(Succeeded: true, IsTransient: false);
private static readonly AppHomingProbeResult ProbeFail = new(Succeeded: false, IsTransient: false);
private static readonly AppHomingProbeResult ProbeTransient = new(Succeeded: false, IsTransient: true);
private static AppHomingDecision Evaluate(
bool isGlobalAdmin = false,
bool selfServiceEnabled = true,
bool legacyConfigured = true,
bool currentlyPrimary = false,
bool targetPrimary = true,
bool force = false,
AppHomingProbeResult? probe = null)
=> AppHomingService.EvaluateManualFlip(
isGlobalAdmin, selfServiceEnabled, legacyConfigured, currentlyPrimary, targetPrimary, force, probe);
[Fact]
public void ParallelWindowInactive_denies_for_everyone()
{
var decision = Evaluate(isGlobalAdmin: true, legacyConfigured: false, force: true);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("parallel-window-inactive", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Theory]
[InlineData(false, false)] // legacy-homed, target legacy
[InlineData(true, true)] // primary-homed, target primary
public void AlreadyAtTarget_is_a_noop(bool currentlyPrimary, bool targetPrimary)
{
var decision = Evaluate(currentlyPrimary: currentlyPrimary, targetPrimary: targetPrimary);
Assert.Equal(AppHomingDecisionKind.AllowNoOp, decision.Kind);
}
[Fact]
public void NonGa_revert_to_legacy_is_denied()
{
var decision = Evaluate(currentlyPrimary: true, targetPrimary: false);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("revert-is-ga-only", decision.ReasonCode);
Assert.Equal(403, decision.StatusCode);
}
[Fact]
public void NonGa_force_is_denied()
{
var decision = Evaluate(force: true);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("force-is-ga-only", decision.ReasonCode);
Assert.Equal(403, decision.StatusCode);
}
[Fact]
public void NonGa_with_flag_off_is_denied()
{
// The kill switch: turning the admin-config flag off must stop tenant-admin flips
// immediately, while the GA path below stays open (operator lever survives).
var decision = Evaluate(selfServiceEnabled: false);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("self-service-disabled", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Fact]
public void Ga_with_flag_off_still_requires_only_the_probe()
{
var decision = Evaluate(isGlobalAdmin: true, selfServiceEnabled: false);
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void FlipToPrimary_without_probe_requires_probe()
{
var decision = Evaluate();
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void FlipToPrimary_with_successful_probe_is_allowed()
{
var decision = Evaluate(probe: ProbeOk);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
[Fact]
public void FlipToPrimary_with_failed_probe_is_denied()
{
var decision = Evaluate(probe: ProbeFail);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("probe-failed", decision.ReasonCode);
Assert.Equal(409, decision.StatusCode);
}
[Fact]
public void FlipToPrimary_with_transient_probe_is_denied_as_retryable()
{
var decision = Evaluate(probe: ProbeTransient);
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
Assert.Equal("probe-transient", decision.ReasonCode);
Assert.Equal(503, decision.StatusCode);
}
[Fact]
public void Ga_flip_without_force_also_requires_probe()
{
var decision = Evaluate(isGlobalAdmin: true);
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
}
[Fact]
public void Ga_force_skips_the_probe()
{
var decision = Evaluate(isGlobalAdmin: true, force: true);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
[Fact]
public void Ga_revert_to_legacy_needs_no_probe()
{
// The legacy app is consented by construction — probing it would be meaningless.
var decision = Evaluate(isGlobalAdmin: true, currentlyPrimary: true, targetPrimary: false);
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
}
}
@@ -0,0 +1,289 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
/// </summary>
public class AppHomingServiceTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
private const string Actor = "admin@contoso.com";
private readonly Mock<AdminConfigurationService> _adminConfigMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<GraphTokenService> _graphTokenMock;
private readonly Mock<IGraphFeatureDetector> _detectorMock;
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
private readonly List<OpsEventEntry> _savedOpsEvents = new();
private readonly EntraAppRegistry _registry;
private readonly AppHomingService _sut;
public AppHomingServiceTests() : this(legacyConfigured: true) { }
private AppHomingServiceTests(bool legacyConfigured)
{
var configValues = new Dictionary<string, string?>
{
["EntraId:ClientId"] = PrimaryId,
["EntraId:ClientSecret"] = "primary-secret",
};
if (legacyConfigured)
{
configValues["EntraId:LegacyClientId"] = LegacyId;
configValues["EntraId:LegacyClientSecret"] = "legacy-secret";
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
_registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
_adminConfigMock = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
_tenantConfigMock = new Mock<TenantConfigurationService>(
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
_graphTokenMock = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance,
Mock.Of<System.Net.Http.IHttpClientFactory>(),
cache,
configuration,
_registry,
_tenantConfigMock.Object)
{ CallBase = false };
_detectorMock = new Mock<IGraphFeatureDetector>();
_maintenanceMock = new Mock<IMaintenanceRepository>();
_maintenanceMock
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var alertDispatch = new OpsAlertDispatchService(
_adminConfigMock.Object,
new TelegramNotificationService(new HttpClient(), configRepo,
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
_sut = new AppHomingService(
NullLogger<AppHomingService>.Instance,
_adminConfigMock.Object,
_tenantConfigMock.Object,
_registry,
_graphTokenMock.Object,
_detectorMock.Object,
_maintenanceMock.Object,
opsService,
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
var config = LegacyHomedConfig();
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
SetupProbe(GraphTokenResult.Success("tok"));
}
private static TenantConfiguration LegacyHomedConfig()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.HomedAppClientId = null;
return config;
}
private void SetupProbe(GraphTokenResult result) =>
_graphTokenMock
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
[Fact]
public async Task Funnel_is_eligible_for_legacy_homed_tenant_with_flag_on()
{
Assert.True(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_is_not_eligible_when_legacy_pair_unconfigured()
{
var sut = new AppHomingServiceTests(legacyConfigured: false)._sut;
Assert.False(await sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_is_not_eligible_for_primary_homed_tenant()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
Assert.False(await _sut.IsFunnelEligibleAsync(config));
}
[Fact]
public async Task Funnel_is_not_eligible_when_flag_off()
{
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
Assert.False(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
}
[Fact]
public async Task Funnel_does_not_block_on_entra_app_roles()
{
// Deliberate: app-role tenants may flip (operator informs them personally); the
// reminder trail is asserted in the flip test below.
var config = LegacyHomedConfig();
config.EntraAppRolesEnabled = true;
Assert.True(await _sut.IsFunnelEligibleAsync(config));
}
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
[Fact]
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
{
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
d != null && d.ContainsKey("HomedAppClientId"))), Times.Once);
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
{
SetupProbe(GraphTokenResult.PermanentFailure());
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task AutoFlip_does_not_flip_on_transient_probe()
{
SetupProbe(GraphTokenResult.TransientFailure());
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task AutoFlip_is_not_eligible_for_primary_homed_tenant_and_never_probes()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
var outcome = await _sut.TryAutoFlipToPrimaryAsync(config, Actor);
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(It.IsAny<string>(),
It.IsAny<EntraAppCredentials>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AutoFlip_is_not_eligible_when_flag_off()
{
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// ── FlipAsync ───────────────────────────────────────────────────────────
[Fact]
public async Task Flip_of_entra_app_roles_tenant_emits_reminder_ops_event()
{
var config = LegacyHomedConfig();
config.EntraAppRolesEnabled = true;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task Flip_noops_when_already_at_target()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
{
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
// the fresh read returning null is a hard stop.
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync((TenantConfiguration?)null);
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_back_to_legacy_persists_null_homing()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
}
}
@@ -24,6 +24,7 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
private readonly AuthFunction _sut;
public AuthFunctionSideEffectTests()
@@ -44,7 +45,7 @@ public class AuthFunctionSideEffectTests
var delegatedAdminMock = new Mock<DelegatedAdminService>(
adminRepo,
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
cache, Mock.Of<ILogger<DelegatedAdminService>>())
{ CallBase = false };
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
@@ -76,6 +77,7 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
_metricsRepoMock = new Mock<IMetricsRepository>();
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
_sut = new AuthFunction(
Mock.Of<ILogger<AuthFunction>>(),
@@ -87,11 +89,15 @@ public class AuthFunctionSideEffectTests
previewMock.Object,
_telegramMock.Object,
_globalNotificationMock.Object,
mcpUserMock.Object);
mcpUserMock.Object,
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
// Default: all fire-and-forget calls succeed
_tenantConfigMock
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()))
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
@@ -126,12 +132,89 @@ public class AuthFunctionSideEffectTests
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
Assert.Equal(Upn, config.OnboardedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Preview Signup",
"preview_signup", "New Tenant Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
null), Times.Once);
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
// Signup enqueues the delayed auto-approve unconditionally — the worker is the
// decision point (flag check at processing time).
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.Is<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
e => e.TenantId == TenantId && e.SignupUpn == Upn),
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------------------
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
// read, never the (possibly stale) cached view. Regression for the prod
// incident 2026-07-31: the login right after an app-homing flip carries the
// changed audience; blind-saving the stale cached entity reverted the flip.
// -------------------------------------------------------------------------
private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
[Fact]
public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip()
{
// Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = LegacyAppId;
cached.HomedAppClientId = null;
var fresh = DefaultConfig();
fresh.DomainName = "contoso.com";
fresh.LastAuthClientId = LegacyAppId;
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
Assert.NotNull(fresh.LastAuthClientIdSince);
}
[Fact]
public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite()
{
// Another instance already recorded the new client id — the fresh re-check must
// suppress the redundant write even though the cached view still looks outdated.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = LegacyAppId;
var fresh = DefaultConfig();
fresh.DomainName = "contoso.com";
fresh.LastAuthClientId = PrimaryAppId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead()
{
// Steady state (same app as last time) must stay a zero-I/O no-op.
var cached = DefaultConfig();
cached.DomainName = "contoso.com";
cached.LastAuthClientId = PrimaryAppId;
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -156,7 +239,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@@ -168,7 +251,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -179,7 +262,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -196,7 +279,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
// SaveConfig should still have been called before the fire-and-forget
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
// -------------------------------------------------------------------------
@@ -217,7 +300,7 @@ public class AuthFunctionSideEffectTests
Assert.Null(config.DisabledReason);
Assert.Null(config.DisabledUntil);
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
@@ -230,7 +313,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -241,7 +324,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
@@ -254,7 +337,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
// -------------------------------------------------------------------------
@@ -223,11 +223,11 @@ public class AuthFunctionTests
}
// -------------------------------------------------------------------------
// Gate 2: Preview Gate
// Gate 2: Activation Gate
// -------------------------------------------------------------------------
[Fact]
public void PreviewGate_NotApproved_Returns403PrivatePreview()
public void PreviewGate_NotApproved_Returns403PendingActivation()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
@@ -238,7 +238,7 @@ public class AuthFunctionTests
Assert.False(result.IsSuccess);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
var body = ToDynamic(result.Body);
Assert.Equal("PrivatePreview", (string)body.error);
Assert.Equal("PendingActivation", (string)body.error);
}
[Fact]
@@ -203,6 +203,38 @@ public class AuthenticationMiddlewareTokenValidationTests
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
}
// ── Dual app-reg window (C4A8 move): primary=new app, legacy=old app via LegacyClientId ──
// The middleware folds EntraId:LegacyClientId into the additional-id set via
// CombineAdditionalClientIdSources, so both apps' tokens validate in bare AND api:// form.
private const string NewAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
private const string OldAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
[Theory]
[InlineData(NewAppId)]
[InlineData("api://" + NewAppId)]
[InlineData(OldAppId)]
[InlineData("api://" + OldAppId)]
public void DualAppWindow_accepts_both_apps_tokens_in_both_audience_forms(string audience)
{
var clientIds = AuthenticationMiddleware.ResolveConfiguredClientIds(
NewAppId,
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, null),
out _);
Assert.NotNull(ValidateWith(Mint(RsaCreds, audience: audience), clientIds));
}
[Fact]
public void CombineAdditionalClientIdSources_joins_and_skips_empty_sources()
{
Assert.Equal($"{OldAppId},{SecondaryClientId}",
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, SecondaryClientId));
Assert.Equal(OldAppId,
AuthenticationMiddleware.CombineAdditionalClientIdSources($" {OldAppId} ", null, ""));
Assert.Null(AuthenticationMiddleware.CombineAdditionalClientIdSources(null, "", " "));
}
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
[Fact]

Some files were not shown because too many files have changed in this diff Show More