Born from /session-debug analysis of f148976f: a customer platform script
(c3e0124c) hung for the full ~30-min IME script-execution timeout, blocking
IME's serial platform-script queue, so the Autopilot-Monitor bootstrap +
agent only started ~34 min after boot (right as ESP hit its own 30-min
SyncFailureTimeout). The agent observed only the already-decided end-state
and lived ~30s — a session that looks like a normal multi-minute failure but
had near-zero coverage of the actual failure window.
Two detectors classify this automatically:
- agent_late_start (Warning, EnrollmentTerminationHandler): fires when the
agent started >=10 min after boot AND lived <=5 min before a terminal
outcome. Device boot via GetTickCount64 P/Invoke (net48 has no
Environment.TickCount64), monotonic / clock-jump-resistant. Shared gate in
Termination/ObservationCoverage.cs (DRY with the builder). final-status.json
gains bootToAgentStartSeconds + lowObservationCoverage (FinalStatusBuilder
stays pure via an optional deviceBootUtc param).
- script_timeout_suspected (Warning, ImeLogTrackerAdapter): a platform script
that ran >=25 min AND ended Failed -> one-shot per policyId, naming it as the
likely enrollment-pipeline blocker. Works on replay (StartedAtUtc from the
CMTrace source timestamp), so it fires even when the script ran before the
agent launched.
Plus per-script run duration for all platform scripts: new
ScriptExecutionState.StartedAtUtc -> durationSeconds on every
script_completed/script_failed -> Web ScriptExecutions renders "Duration"
(amber past 10 min) so slow/inefficient scripts surface generally.
Scope: platform scripts only (remediation/health-script duration deferred --
the multi-source HS reducer makes clean attribution fragile). No ConfigVersion
bump, no backend code (ingest is event-agnostic). New event types wired through
the mandatory dual-sync: Constants.EventTypes -> MCP resource-catalog ->
Web eventTypes (drift tests enforce).
Tests: V2.Core 1536 (+88), MCP drift 6, MCP/Web tsc 0, Web 84. The f148976f
case is reproduced in unit tests (ObservationCoverage 34min/30s; adapter
c3e0124c 30min/Failed). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a user authenticated successfully but was not on the MCP whitelist,
the MCP server returned an opaque "MCP access denied" 403 — the user could
not tell why auth was interrupted or that they simply needed to be
whitelisted by the server owner.
- access-guard: distinguish a genuine authorization denial (not whitelisted)
from an infrastructure failure (backend unreachable / malformed) via a new
infraError flag on AccessCheckResult. Only a genuine deny gets the
user-facing "User not enabled for MCP usage" error plus an actionable
message naming the account and pointing at the fix (ask to be whitelisted).
Infra failures keep a neutral "MCP access check unavailable" message so the
user is not sent chasing the wrong fix. The reason field still echoes the
backend reason verbatim.
- backend McpUserService: WhitelistOnly denial reason is now self-explanatory
("User not enabled for MCP usage (account is not on the MCP whitelist)").
- tests: cover the actionable whitelist message and that an infra failure is
not mislabelled as a whitelist problem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKYavm4FAu66qnhX5nvmMT
The ticket-gated diagnostics/download route (PublicAnonymous, MCP session_debug)
401'd on the deployed backend: AuthenticationMiddleware kept its OWN hardcoded
_anonymousRoutes allowlist, parallel to EndpointAccessPolicyCatalog, and the new
route was added only to the catalog. Since AuthenticationMiddleware runs before
PolicyEnforcementMiddleware, the missing entry rejected the request with a JWT
401 before the PublicAnonymous policy was ever honored. Classic hand-synced
parallel-catalog drift.
Delete _anonymousRoutes / _anonymousPrefixes and derive the JWT-exempt decision
from the catalog: a route skips JWT iff its policy is PublicAnonymous or
DeviceOrBootstrapAuth (authenticated in-function via cert/bootstrap token, not
JWT). Now method-aware and fail-closed on unregistered routes. Behavior-
preserving: the old 14 routes + 1 prefix equal exactly the catalog's
PublicAnonymous u DeviceOrBootstrapAuth set.
Add drift guard in EndpointPolicyCatalogCompletenessTests:
AnonymousAndDeviceRoutes_AreJwtExemptInAuthMiddleware loops every such catalog
entry and asserts the middleware exempts it (would have caught this bug);
SkipsJwtValidation_MatchesPolicyTier locks the positive/negative tiers incl. the
diagnostics/download vs diagnostics/download-url contrast. Full suite 2393 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.
Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
(web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.
MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
+ instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
pure chat clients cannot unzip).
Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two V2 IME fixes, verified against client logs of sessions fc48c71a / 6b4993e5.
Bug 1 — on an ESP terminal failure of the DeviceSetup Apps subcategory the only
device app was stuck mid-install (RealmJoin stuck Downloading 0%) and IME logged
no per-app error, so app_tracking_summary showed 0 failed and the failure was
visible only via the opaque "Apps (Error)" registry string. New
esp_apps_failure_correlation event names the in-flight (IsActive: InProgress/
Downloading/Installing) device app(s) as the likely cause WITHOUT mutating state
(no fabricated app_install_failed) and only on the Apps subcategory.
Bug 2 — AgentExecutor.exe hosts both platform and proactive-remediation scripts
interleaved in the shared AgentExecutor.log; the policyId-less PS-AGENT-EXITCODE /
PS-AGENT-OUTPUT lines from a remediation invocation bled into the last-started
platform script via the sticky pointer (c3e0124c emitted result=Failed but with
exit 0 + the Teams remediation's "[Compliant] No Classic Teams found" stdout).
New PS-AGENT-INVOCATION pattern (resetPlatformScriptContext) clears the platform
line-capture pointer at every "ExecutorLog AgentExecutor gets invoked" boundary,
so a remediation invocation cannot capture into a platform slot; the platform
result stays keyed by policyId via PS-SCRIPT-RESULT.
Agent-only, V2, inert until redeploy; no ConfigVersion bump. New event type added
to the MCP resource catalog (dual-sync). Green: V2.Core 1522, DecisionCore 400,
Backend BuiltInRules 8, MCP vitest 252.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GA "Edit Tenant Configuration" dialog only exposed Disable/Remove for
tenant members, while the tenant-admin Access Management view already had a
role dropdown. Backend already permits GA on the permissions endpoint
(TenantAdminOrGA), so this brings the GA component to parity:
- per-member Admin/Operator role dropdown (handleUpdatePermissions)
- "Can manage bootstrap tokens" toggle for Operators
- role selector on Add New Admin (POST now sends role)
Web-only, no ConfigVersion bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 emitted script_completed for platform scripts ONLY on the IME PS-SCRIPT-RESULT
line (IntuneManagementExtension.log). The AgentExecutor.log exit code + stdout merely
filled _pendingPlatformScripts and never emitted on their own. IME logs its policy
result after its batch-send to the service, so scripts that finish shortly before the
(often short) Autopilot enrollment ends were silently dropped — the buffer is neither
persisted nor flushed on dispose. Field-confirmed on session d1da052d: 4 platform
scripts ran, only the long-running one (whose IME result was logged in time) reached
the backend.
Add a deadline-based fallback in the live polling loop: a pending platform script with
a known exit code but no PS-SCRIPT-RESULT within a 15s grace is emitted from the exit
code (0 -> Success, else Failed), tagged resultSource=agentexecutor_fallback so the
provenance is honest. A force pass runs on shutdown. Dedup via _platformScriptResultEmitted
prevents a double emit when the authoritative result arrives after the fallback; a fresh
scriptStarted clears the marker so IME re-evaluations/retries of the same policy emit again.
The fallback event binds to the script's own exit timestamp (not the last-matched line)
and omits the stale patternId.
Reuses script_completed/script_failed (no new event type, no ConfigVersion bump, no MCP
catalog change). V2-only; legacy V1 agent unchanged. 1516/1516 V2.Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate copy-blueprint rules (templateVariables, e.g. PPKG allow-list
SEC-005/006) from real rules into their own tab so users see they are
templates, not directly-active rules. Customer feedback.
- New pure helper lib/analyzeRuleTabs.ts (isTemplateRule, partitionAnalyzeRules) + test
- page.tsx: activeTab state; tab bar shown only when >=1 template exists
(unchanged for tenants without templates); Type filter hidden/ignored in
Templates tab; create-new only in Rules tab; after create-from-template,
switch to Rules tab + auto-expand the new custom copy
- AnalyzeRuleCard: new variant="template" prop -> static glyph instead of
toggle + banner: green "already created: {id}" with View/edit jump, or
amber "Create custom rule". Default-list behaviour untouched.
Web-only, no Backend/Shared/ConfigVersion change. tsc clean, 469/469 vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable,
channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems,
automation, or an SMTP gateway like Postal - instead of only Teams/Slack.
Backend:
- GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity,
title/summary/themeColor, primaryUrl (first openUrl action), facts[],
sections[], actions[]; registered in WebhookNotificationService.
- NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/
_failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved,
consecutive_failures, test) for consumer-side routing.
- Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only,
restricted-header denylist, fail-soft parse) for API-key auth; dispatcher
gained optional customHeaders applied via HttpRequestMessage; threaded into
all 12 tenant call sites (Ops/admin-config sites unchanged).
- TableConfigRepository persists the field; UpdateTenantConfigurationFunction
validates it (object, string values, RFC-7230 token names, CRLF-injection
guard, 8 KB / 25-header caps).
Web:
- Generic provider option + key->value WebhookHeaderEditor (rendered only for
provider 20); leaving the generic provider clears local headers so a later
switch back cannot revive stale auth headers; context/types wiring sends
headers only when provider===20; report shows Generic JSON provider name.
ConfigVersion unchanged (backend-only, no agent payload). No new
Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP
client / retry by design). New GenericWebhookTests cover renderer, header
parsing/denylist, and save-time validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Surface WMI watcher arm failures: new ConsoleBypassWatcher.WatcherArmFailed
event -> host emits collector_degraded (reason=watcher_arm_failed) via
CollectorDegradationReporter, so the backend can tell "no console seen" from
"live detector never started" instead of both looking like a clean run.
- Close a startup blind gap: arm the live Win32_ProcessStartTrace FIRST, then
run the startup snapshot. A cmd spawned between snapshot and arm was previously
missed; the _seen set dedups the now-possible overlap.
- Fix command-line classification for combined cmd switches: strip the executable
token, then match /c|/k anywhere in the arguments (was anchored to whitespace),
so e.g. `cmd /d/q/c exit 9` is correctly scripted (was false high-confidence).
- Docs: replace stale parent-based wording (AgentConfigResponse, the Web analyzer
toggle help text, the prefetch coverageNote) with the interactive-session model.
Tests: combined-switch + StripExecutable cases added. V2.Core 1505/1505.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field session f240a555 (Win11 24H2) disproved the parent==winlogon assumption:
the Shift+F10 cmd's parent (pid 9016) was a short-lived non-winlogon launcher
that exited within ms ("parent resolve failed ... is not running"), and the cmd
ran as defaultuser0, not SYSTEM. The conservative "can't confirm winlogon -> don't
flag" path therefore dropped a real Shift+F10 console.
ConsoleBypassWatcher rewritten to be parent-free:
- Gate race-free on SessionID != 0 (interactive session) from the trace — IME/
Intune install `cmd /c` run as SYSTEM in session 0 and are dropped without a probe.
- Confirm on the cmd's OWN command line: bare (no /c|/k) = interactive shell ->
confidence=high; a scripted `cmd /c` is ignored.
- Instant-close race (command line unreadable before the probe) -> still surfaced
as confidence=low (interactive_session_unclassified) rather than missed.
- Event data now: sessionId, owner, commandLine, confidence, classification,
parentProcessId (dropped parentProcessName/parentMatchesWinlogon).
Lifecycle gate: ConsoleBypassHost.StopForDesktopArrival() wired into the
DesktopArrival onRealUserOwnerObserved callback — once the real-user desktop
arrives (excludes SYSTEM + defaultuser0) Shift+F10 is no longer possible, so the
watcher stops and cannot flag ordinary post-enrollment user consoles. AccountSetup
phase alone is too early (the portal shows it before the real desktop, while
Shift+F10 still works), so desktop arrival is the binding stop signal.
Tests rewritten: Classify + HasScriptArgument (pure), high/low-confidence,
session-0 ignore, scripted ignore, dedup, owner enrichment. V2.Core 1499/1499.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Detects a SYSTEM console opened during enrollment — the classic Shift+F10
OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary
Warning signals, one per-tenant toggle EnableConsoleBypassDetection
(default ON / opt-out), ConfigVersion 31 -> 32:
- oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost.
WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a
cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a
confirmed winlogon match, so an ignored cmd cannot suppress a later real
one via PID reuse.
- console_prefetch_detected (startup forensic): ConsolePrefetchScanner
(IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is
after boot, covering the pre-agent OOBE window the live watcher misses.
stat-only; corroborating artifacts sorted for a stable gate fingerprint.
Both events carry coverageComplete:false + an honest coverageNote (and the
prefetch event attribution:"coarse") — detection is best-effort, not gapless,
and only reports, never blocks.
Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web
eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig
(default true) + TableConfigRepository; DefaultComponentFactory host gate +
AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher
classification (9) + prefetch scanner (7) + analyzer-manager on/off.
Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value
because onSave() read async React state. saveConfiguration now takes an
explicit unrestrictedMode override that the toggle passes through, so the
saved value matches the UI state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Webhook notifications built /session/{tenantId}/{sessionId}, which is a
dead route (404). The web app route is /sessions/{sessionId} (plural,
single segment); tenant is resolved from the session, not the path.
Fixes broken "Open session" links in enrollment-started and
success/failure notifications.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real misalignment was the icon, not the label. Nav icons are
created without a className (e.g. <HomeIcon/>), so each SVG keeps its
component default w-5 h-5 (20px) while the wrapper span only sized the
span to w-4 h-4 (16px). A 20px inline, baseline-aligned SVG inside a
16px box does not sit at the box's vertical center, so it never lined
up with the adjacent text — and no label nudge could fix that.
Make each icon wrapper an inline-flex with items-center so the SVG is
centered as a (blockified) flex item, independent of the size mismatch
and free of inline-baseline offset. Drop the earlier 1px label nudges,
which compensated the wrong element.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0.5px under-corrected the optical high-sitting of full-mode nav labels;
bump to 1px so text centers on its icon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Nudge full-mode nav labels down 0.5px so text optically centers on
its icon (font ascender slack made labels sit slightly high).
- Wrap bare <li> usages (Dashboard, regular-user items, public Home)
in <ul> — a li without a ul parent kept display:list-item and
rendered a default disc ::marker (invalid HTML).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In larger tenants the multi-tenant app is often pre-approved by someone
with consent rights, while the admin operating our UI lacks them. The
/adminconsent redirect then fails, so ValidateAutopilotDevice/
ValidateCorporateIdentifier never persist and the tenant stays "Disabled"
even though the app + DeviceManagementServiceConfig.Read.All effectively
exist. That bool is also the hard gate in SecurityValidator, so a UI-only
badge would leave agent traffic 403'd.
Add a read-only access-check probe that verifies the concrete role in the
SP token's roles claim (via GraphFeatureDetector, not mere token
acquirability) and, when present, persists the gate bool through the
normal config PUT — opening the UI badge AND the agent gate together.
Backend:
- GraphAppPermissions.DeviceManagementServiceConfigReadAll constant.
- GET /api/config/{tenantId}/autopilot-device-validation/access-check
(TenantAdminOrGA): InvalidateTenant + fresh GetSnapshotAsync (4s budget),
pure ResolveAccessPresent (transient => never present), emits
ConsentAccessDetected telemetry. Always-fresh so a propagation-time
empty-roles snapshot can't wedge the admin out for the token TTL.
Web:
- lib/accessCheck.ts classifyAccessCheck pure fn (transient/absent never
reconcile) + tests.
- Reconcile on consent-redirect failure (silently enables) and a per-toggle
"Detect existing access" button for Autopilot, Corporate, and DevPrep
Device Association (all share the same role).
- Successful consent callback now verifies the concrete role too: persists
ONLY on authoritative "reconciled"; transient => retry/propagation
message, absent => "permission not granted" — neither opens the gate.
- saveConfiguration returns boolean so success is shown only after a
confirmed persist.
No ConfigVersion bump (backend + web only). Backend 2326/2326, web 464/464.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DiagnosticsUploadEnabled was gated solely on the per-tenant
DiagnosticsBlobSasUrl. Hosted-destination tenants have no such URL
(the platform owns the storage), so the agent received
DiagnosticsUploadEnabled=false and silently skipped every upload —
even with DiagnosticsUploadMode=Always. No diagnostics_collecting /
_uploaded / _upload_failed events were ever emitted.
Extract the gate into ResolveDiagnosticsUploadEnabled and enable when
a customer SAS is set OR the destination is Hosted. Config-only change,
no ConfigVersion bump; applies on next agent config poll after deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related fixes for the end-user Progress Portal experience:
Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression c4dabeee), which 403'd roleless end users tracking
their own device - the page loaded initial status but never received live
eventStream pushes (looked frozen). Introduce a new EndpointPolicy tier
AuthenticatedUserWithRole that admits any authenticated caller but still
resolves IsGlobalAdmin/IsTenantAdmin/UserRole onto RequestContext, so the
SignalR functions can do per-group authorization. Real gating now lives in
SignalRAddToGroup/RemoveFromGroupFunction: tenant match, global-admins group
on GA, admin-tier notify group on TenantAdmin, member-tier notify group on
tenant membership. Plain AuthenticatedUser was rejected - it would leave the
role flags always-false and break GA/admin group joins.
Sidebar: hide the global Dashboard nav item for regular users (no
admin/operator access); they only see the Progress Portal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grant tenant member roles via Entra ID app-role assignments on the
Enterprise App (the token's "roles" claim), in addition to the
TenantAdmins table. Per-tenant opt-in via the new GA-only
EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump).
Resolution is table-first and tri-state (NotPresent/Disabled/Enabled):
an enabled table row wins, a disabled row is an explicit deny that
suppresses the claim entirely (a disabled member can never be
re-authorized through their app-role assignment), and only a missing
row falls back to the claim. Only Admin and Operator are claim-mappable
(Admin outranks Operator); Viewer and the platform GlobalAdmin role are
never claim-derived.
All effective-role consumers share one pure decision authority
(EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me,
and feedback/status — the latter previously used a table-only check, so
Entra-only members did not see the feedback bubble.
GA toggle wired through the BootstrapToken pattern: TableConfigRepository
persistence, UpdateTenantConfigurationFunction GA-only protection, and a
checkbox in the admin tenant editor.
Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an
auto-promote regression guard; 2308/2308 backend green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetAllTenantIdsAsync answered "which tenants exist?" by scanning the entire
Sessions table (PK projection) — an O(corpus) cost paid multiple times per 2h
maintenance run by all 6 callers (stalled/excessive sweeps, retention fanout,
platform-stats recompute, distress-report cleanup). It grows with session
history rather than tenant count.
Read the TenantConfiguration table instead (one "config" row per tenant, same
proven pattern as TableConfigRepository). Same return shape, so all callers
benefit with no interface or DI change.
Trade-off (documented in code): a fully-offboarded tenant whose config row was
deleted but whose old sessions still linger is no longer visited by maintenance
sweeps. Acceptable — the offboarding cascade removes those sessions anyway, and a
merely disabled tenant keeps its config row and is still processed.
Not bundled here: day-windowing RecomputePlatformStatsAsync — its counters are
cumulative/all-time and need separate semantic review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V2 telemetry ingest path (TelemetryPayloadParser) skipped the
Newtonsoft-JToken -> native normalization the V1 NDJSON path applied. Nested
values (e.g. hardware_spec.disks/gpus) stayed as JObject/JArray and reached
System.Text.Json downstream in UpsertDeviceSnapshotAsync, serializing to corrupt
nested-empty-array output (e.g. "disks":[[[[]],[[]]]]). Verified on live data.
- Extract shared EventDataNormalizer (Normalize + NormalizeMap); both ingest
paths and the read path (TableStorageService.DeserializeEventData) now use it,
consolidating 3 duplicate copies of the same logic into one.
- TelemetryPayloadParser.ParseEvent now normalizes after deserialize.
- DetectHasSSD: exact Equals("SSD"/"NVMe") -> case-insensitive Contains. The
agent reports composite media types ("NVMe SSD") so the exact compare matched
nothing and hasSSD was false on every row, independent of the corruption.
- New EventDataNormalizationTests cover nested normalization, STJ round-trip
safety, the read-path map, and DetectHasSSD matching.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SELECT ProcessId omitted the key property Handle, so WMI could not construct
__PATH and InvokeMethod("GetOwner") throws under classic WMI — the entire WMI
fallback in ProcessOwnerLookup.ViaWmi would have been silently dead
(throw → catch → wmiErrored=true → null), a regression vs the old SELECT *.
SELECT Handle, ProcessId restores method-callability. Not caught by tests: the
WMI path has no direct coverage (DAD tests inject the ProcessOwnerResolver seam).
V2.Core 1467/1467 green, build 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four byte-identical `SELECT * FROM Win32_Process … GetOwner` blocks resolved the
explorer.exe owner independently. Consolidate into a single WTS-primary helper
(Monitoring/Interop/ProcessOwnerLookup) so DesktopArrivalDetector, UserProfileResolver,
LocalAdminAnalyzer and UserSessionProcessLauncher share one implementation.
- WTS-primary (WTSQuerySessionInformation), WMI fallback — the other 3 sites inherit
the 4d5a0b78 robustness win (device where GetOwner failed on every poll).
- WMI fallback projects SELECT ProcessId (GetOwner binds via __PATH) instead of SELECT *.
- ViaWmi(pid, out bool wmiErrored) preserves DAD's _wmiErrorCountSinceStart liveness
counter exactly (vanished-PID zero-row case is not an error); DAD keeps its counters
and SessionOwnerResolver/ProcessOwnerResolver test seams.
- Constraint documented: WTS resolves the session user, equal to the process owner only
for explorer.exe; all 4 sites are explorer-only + skip SessionId==0.
V2.Core 1467/1467 green, build 0 warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keeps the device awake (system + display via SetThreadExecutionState) during the
User-ESP / AccountSetup phase so idle standby/sleep can't stall app installs or
account provisioning. Reboots are unaffected; the OS auto-clears the hold on
process exit, so it can never leak.
- UserEspKeepAwakeHost observes the signal rail: engage on AccountSetup phase,
release on AccountSetupProvisioningComplete / host stop / safety cap.
- Safety cap is derived from the tenant's Intune ESP SyncFailureTimeout (+30 min
margin), read at engage time; falls back to 90 min when unavailable. Resolved
cap + source are reported on keep_awake_engaged for fleet observability.
- KeepAwakeController owns the hold on a dedicated thread (ES_CONTINUOUS bound to
the thread's lifetime). Dispose guard on the engage-ready timeout path.
- Per-tenant portal toggle KeepAwakeDuringUserEsp (ConfigVersion 30 -> 31) with
full config plumbing (Tenant/Analyzer config, table storage, agent delivery)
and Web settings UI.
- New events keep_awake_engaged / keep_awake_released across Constants, the MCP
resource catalog, and the Web gather-rule on_event autocomplete.
- Tests: KeepAwakeController late-confirm timeout edge, host engage/release/cap,
and pure cap-resolution policy (V2.Core 1467 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Point MCP integration docs at the live Container Apps URL
(kindwave-…azurecontainerapps.io/mcp); the old azurewebsites.net
hostname was stale in both the Server URL block and the VS Code snippet.
- Revise the Available Tools catalog to reflect what a tenant (non-GA)
user actually sees: drop the Global-Admin-only get_api_usage and
list_blocked_devices; add the missing non-GA tools
(get_app_install_metrics, get_vulnerability_summary, get_rule_stats,
get_ime_version_history, get_software_inventory, get_resource) with
descriptions; strip Global-Admin wording from remaining entries.
- Correct the Rate Limits section: fixed 60 req/min sliding-window per
user with HTTP 429 retryAfterSeconds=60 — remove the non-existent
"usage plans" claim and the GA-only get_api_usage reference.
- Add the matching changelog entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OfficeInstallDetector fired a false office_install_failed on devices with
pre-installed OEM/consumer inbox Office (e.g. O365HomePremRetail +
OneNoteFreeRetail) running a background C2R CLIENTUPDATE/maintenance scenario:
the already-resident Office was mis-read as a failed Microsoft 365 Apps install,
with a benign small-integer scenario value ("2") treated as a failure code.
Field telemetry (202 office_install_started events) showed 200/202 real installs
start with streamingFinished=false (a live download) and only 2 already-resident
— so the clean discriminator is "already on disk", not the scenario name. A
"require INSTALL scenario to start" gate was rejected: 47% of real starts fire
via the early DO-CDN job before C2R writes its registry.
Changes:
- New office_preinstalled_detected (Info): emitted when the core Office binaries
are already on disk at the first signal AND the product set is consumer/OEM
inbox (OfficeProductClassifier). Anchored on the filesystem, NOT the flaky
StreamingFinished value (absent on some machines). Enterprise Office already on
disk keeps the started->completed path (CSP/Win32-wrapper case).
- Does NOT latch terminal: an enrollment often uninstalls the inbox Office and
installs a fresh one, so the detector stays armed (emit-once guard +
StatePreinstalled persisted for cross-reboot dedup, ResumePreinstalled set
before the process watcher's startup probe).
- Error attribution scoped to the INSTALL scenario only (+ errorSource); a
CLIENTUPDATE error never fails an install.
- Registry idle-start gated on InstallScenarioPresent (not any scenario).
- IsNonZeroNumericCode rejects implausibly small positive decimals (<1000).
- DO bytes are seeded only once a real lifecycle opens, so a preinstalled/
CLIENTUPDATE download cannot leak into a later install's doSummary.
- Web InstallProgress renders a sky "Pre-installed" info row; MCP + web event
catalogs updated.
V2.Core 1454/1454, web tsc clean, MCP 240 passed. Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
disk_free_gb was previously only visible inside the Debug performance_snapshot
payload (filtered out of timelines), so a disk-starved enrollment carried no
actionable signal. PerformanceCollector now emits a dedicated disk_space_low
Warning (ImmediateUpload) when the system drive crosses below 2 GB free.
State-change-only (no heartbeat): strict <2 GB fire, re-arm only above 3 GB
(1 GB hysteresis) so a single enrollment that expands then cleans its temp
cache can warn again without flapping. Decision lives in pure, testable
PerformanceCollector.EvaluateDiskLowTransition(double, ref bool).
- new Constants.EventTypes.DiskSpaceLow = "disk_space_low"
- 'disk_space_low' added to MCP resource-catalog health_events (drift sync)
- PerformanceCollectorDiskLowTests (7 cases); guard + drift tests green
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The office_install_completed doSummary paired aggregateFileSize next to
totalBytesDownloaded with no context, making the (expected) gap look like a
bug: the completed event fires at core-binary completion while background
streaming of the remaining declared content is still in flight, so the peak
DO sample shows downloaded < declared. aggregateFileSize is the sum of the
DO jobs' declared target sizes at the peak instant, not a stable payload total.
Mirror downloadPercent (from OfficeDoSample.DownloadPercent) and
streamingFinished into the summary so the gap is self-explanatory. XML-doc
now states why aggregate exceeds downloaded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RealmJoinWatcher was always-on, gated only by a compile-time const. Most
tenants do not deploy via RealmJoin, so the watcher and its 60-min completion
gate were pure noise. Add a per-tenant portal toggle (Agent Analyzers ->
RealmJoin Watcher), default off, delivered to the agent via remote config.
Mirrors the EnableIntegrityBypassAnalyzer flow end-to-end, with one divergence:
RealmJoin is a collector host (not an IAgentAnalyzer), so the agent-side gate
lives in DefaultComponentFactory.CreateCollectorHosts, not AgentAnalyzerManager.
When disabled the host is not created; the existing null-guard in the
DesktopArrival callback handles the absent HKU arming. The compile-time const
RealmJoinTrackingEnabled is kept as a build-time master kill-switch
(effective enable = remote flag AND const).
- Shared: TenantConfiguration.EnableRealmJoinWatcher (bool?, null=agent default
false) + AnalyzerConfiguration.EnableRealmJoinWatcher (bool=false)
- Backend: TableConfigRepository write/read; GetAgentConfigFunction maps
?? false into Analyzers; ConfigVersion 29 -> 30
- Web: types + TenantConfigContext (state/load/save/reset/deps/context value) +
SectionAgentAnalyzers + new "RealmJoin Watcher" subsection toggle
Inert until agent redeploy: old agents ignore the flag and stay always-on.
Verified: solution build 0 errors, tsc --noEmit clean, 76 backend config +
99 V2.Core tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 never wired the Legacy EnrollmentTracker's CollectAtEnrollmentStart /
CollectAtEnd (plan 5.8 TODO) - the device-info sweep ran only at agent
start. For IME-deployed agents the per-reboot restart re-ran CollectAll
and picked late values up eventually, but image-deployed agents
(--await-enrollment resumes the moment the MDM certificate appears,
i.e. at the very beginning of provisioning) collect an almost-empty
surface, and a session without a reboot between agent start and
completion never refreshed at all: aad_join_status stuck on
"Not Joined", autopilot profile / FirstSync ESP config stale, BitLocker
end state (enabled via policy DURING enrollment) never captured.
DeviceInfoHost now subscribes to the central signal stream (same
pattern as ProvisioningPackageHost) and mirrors the V1 behavior:
- CollectAtEnrollmentStart once on the first DeviceSetup phase signal
(re-fetches AAD join, autopilot profile, ESP config, TPM)
- CollectAtEnd once on FinalizingSetup or desktop arrival - desktop
arrival also serves the no-ESP / WDP v2 case and runs the
enrollment-start refresh if DeviceSetup was never seen
- repeat posts of the EspConfigDetected decision signal are explicitly
safe (reducer set-once with late-fill semantics)
This was deferred partly because every re-collect used to mean 4-6
duplicate events; with the StartupEventGate emit-on-change dedup the
re-collections only emit values that actually changed.
V2.Core tests 1410/1410 green (2 new); V2 runtime builds clean.
Inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One enrollment session spans several reboots; every reboot restarts the
agent process and re-ran the one-shot startup checks, re-emitting ~20
events that carried no new information (device-info sweep, geo/timezone/
NTP probes, startup analyzers). Multi-reboot sessions multiplied this
into significant duplicate-event volume; the timezone probe even re-ran
tzutil on every boot.
New StartupEventGate (startup-event-state.json in the state directory,
fail-soft, atomic write, thread-safe) with two policies:
- emit-on-change: payload-fingerprint dedup for the 13 DeviceInfoCollector
events (central EmitDeviceInfoEvent hook; enrollment_type_detected now
routed through it too), software_inventory_analysis startup baseline,
local_admin_analysis (startup trigger only) and
integrity_bypass_analysis. Identical repeats are suppressed; a real
change (aad_join_status flipping after the Hybrid-Join reboot,
BitLocker conversion progressing) always re-emits. The gate only ever
wraps the EVENT emission - collection, decision signals and derived
facts (HasAadJoinedUser, EspConfigDetected reducer signal, inventory
delta base) run unchanged.
- retry-until-success: device_location, timezone_auto_set and
ntp_time_check skip entirely (no network call / tzutil) once they
succeeded in a previous run; failures keep retrying on the next start.
NTP latches only at offset <= 60s (shared constant with the severity).
Deliberately ungated: power_state_check (battery drain across the
enrollment is real per-boot information), boot_time, wifi_signal_info,
agent_started, and all shutdown emissions (the local-admin and
software-inventory start-vs-end delta comparisons stay intact).
The network_interface_info fingerprint excludes linkSpeedMbps (the
negotiated WiFi rate varies per association and would defeat the dedup).
One shared gate instance per process, created in AgentRuntimeHost and
passed to DefaultComponentFactory, AgentAnalyzerManager and
StartupEnvironmentProbes.
V2.Core tests 1408/1408 green (25 new); V2 runtime builds clean.
No ConfigVersion bump, no new event types; inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cold starts spent 35-55s (measured across 8 boots today) embedding the
static corpus — 119 knowledge docs + 144 event types — through MiniLM
on the 0.25 vCPU container before the server even started listening.
The corpus is baked into the image, so its embeddings now are too:
- precompute-embeddings.ts runs in the Docker build stage (also serving
as the model prefetch), writing search-index.json with per-section
sha256 corpus hashes.
- At boot, index.ts hydrates both vector indexes from that file in
milliseconds (135 ms measured locally). Model name + corpus hash are
validated first; any mismatch (rules changed without rebuild, model
bump, malformed file) logs loudly and falls back to computing at
boot — slow but never stale. SEARCH_BACKEND=fuse and the fuse boot
fallback are unaffected.
- The query embedder is no longer needed at boot; it warms in the
background after the indexes are ready. getEmbedder now memoizes the
promise instead of the resolved pipeline — a query racing the warmup
previously could have constructed the model twice (~2x model RAM on
a 0.5 Gi container).
- 14 new model-free unit tests: hash stability/sensitivity, validation
rejection paths, serialize/hydrate roundtrip.
Expected cold start drops from ~60-80s to infra-only (~15-25s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OfficeInstallDetector state was in-memory only, so every agent
restart (an enrollment spans several reboots) re-armed the detector
from Idle and all three start triggers fired falsely on an
already-installed Office: Scenario\INSTALL persists post-install,
OfficeC2RClient.exe runs again for update checks, and Office updates
stream from the same CDN. Each restart then emitted a duplicate
started+completed pair (~0s duration) via the binary watcher's
initial scan; a mid-install reboot also reset the start time.
New OfficeInstallStatePersistence (office-install-state.json in the
state directory, fail-soft, atomic write) carries the lifecycle:
- persisted terminal (Completed/Failed) -> the factory does not arm
the detector at all on the next run (no WMI/RegNotify watchers)
- persisted Active -> the host resumes the open lifecycle without a
second started; a completion proven in this run reports the true
total duration plus the persisted DO peak as doSummary (also
catches completions missed across a mid-install reboot)
- AbandonSilently deliberately does NOT persist a terminal: it fires
on every dispose and would block that resume
- DO peak growth re-persists throttled (30s) while Active
V2.Core tests 1383/1383 green (14 new); V2 runtime builds clean.
No ConfigVersion bump (no new remote setting); inert until redeploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>