Commit Graph

1739 Commits

Author SHA1 Message Date
Oliver Kieselbach d5cb5ef532 fix(agent): rework Shift+F10 console detection — parent-free discriminator + desktop-arrival gate
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>
2026-06-19 12:47:07 +02:00
Oliver Kieselbach 2e3c0b8db6 feat(agent+backend+web): OOBE-console / Shift+F10 detection (default-on opt-out)
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>
2026-06-19 11:29:41 +02:00
Oliver Kieselbach b71df9fc18 fix(backend): correct webhook session URL to /sessions/{id}
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>
2026-06-19 10:55:02 +02:00
Oliver Kieselbach 04c2482f33 fix(web): vertically center sidebar nav icons (root cause)
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>
2026-06-19 10:26:22 +02:00
Oliver Kieselbach a60ef54a23 fix(web): increase sidebar nav label vertical nudge to 1px
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>
2026-06-19 10:05:32 +02:00
Oliver Kieselbach e285e93b00 fix(web): sidebar nav vertical alignment + stray list marker
- 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>
2026-06-19 09:40:10 +02:00
Oliver Kieselbach 8862d73eed feat(backend+web): detect pre-approved consent for rights-less admins
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>
2026-06-18 18:41:34 +02:00
Oliver Kieselbach b515db07d7 fix(backend): enable diagnostics upload for Hosted-destination tenants
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>
2026-06-18 18:04:17 +02:00
Oliver Kieselbach ecd8036dc0 updated guardrails 2026-06-18 17:40:07 +02:00
Oliver Kieselbach 7c55ee6d0c small ui fix for SLA 2026-06-18 12:49:29 +02:00
Oliver Kieselbach 5775c7c176 fix(web+backend): Progress Portal live updates for end users + sidebar Dashboard gate
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>
2026-06-18 11:21:59 +02:00
Oliver Kieselbach 879bd0c59b feat(backend): optional per-tenant Entra app-role RBAC (Admin/Operator)
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>
2026-06-17 18:54:39 +02:00
Oliver Kieselbach 83ce5f9fdf Merge pull request #74 from okieselbach/fix/v2-snapshot-corruption-and-tenant-scan
fix(backend): V2 DeviceSnapshot corruption + maintenance tenant-list scan
2026-06-16 17:31:43 +02:00
Oliver Kieselbach f67341dd70 perf(backend): derive maintenance tenant list from TenantConfiguration, not a Sessions scan
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>
2026-06-16 17:30:11 +02:00
Oliver Kieselbach ea81b1eca0 fix(backend): normalize V2 ingest event data to prevent DeviceSnapshot corruption
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>
2026-06-16 17:30:02 +02:00
Oliver Kieselbach a55a7c65af fix(agent-v2): include Win32_Process key (Handle) in WMI GetOwner projection
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>
2026-06-16 16:07:43 +02:00
Oliver Kieselbach a0a380ea29 refactor(agent-v2): consolidate 4 dup Win32_Process GetOwner copies into ProcessOwnerLookup
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>
2026-06-16 15:58:35 +02:00
Oliver Kieselbach ec9ca067b2 update deps 2026-06-16 15:05:59 +02:00
Oliver Kieselbach e84ac248f9 feat(agent-v2): opt-in keep-awake during User-ESP phase (per-tenant, default off)
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>
2026-06-16 15:01:52 +02:00
Oliver Kieselbach 4ebf661319 docs(web): fix MCP server URL + list only non-GA tools, correct rate-limit text
- 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>
2026-06-16 10:00:22 +02:00
Oliver Kieselbach d3d9c5f2cf update agent changelog 2026-06-16 09:41:32 +02:00
Oliver Kieselbach cd29cb86c1 feat(agent-v2): detect pre-installed OEM/consumer Office, kill false office_install_failed
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>
2026-06-15 21:34:08 +02:00
Oliver Kieselbach edfb6bddc2 feat(agent-v2): emit one-shot disk_space_low warning below 2 GB (MON-B10)
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>
2026-06-15 16:35:43 +02:00
Oliver Kieselbach 8e35eb1b57 feat(agent-v2): surface downloadPercent + streamingFinished in Office doSummary
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>
2026-06-15 15:59:03 +02:00
Oliver Kieselbach b956de1377 feat(realmjoin): make RealmJoin watcher portal-configurable per tenant (default off)
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>
2026-06-15 15:52:31 +02:00
Oliver Kieselbach 301d8ab4de added outbound ip trace event 2026-06-15 14:39:46 +02:00
Oliver Kieselbach 926ff328b2 minor UI fixes for Software Mapping 2026-06-15 14:31:13 +02:00
Oliver Kieselbach 09c4d4b4b7 feat(agent-v2): refactor RecoveryCoordingator extraction 2026-06-15 12:18:14 +02:00
Oliver Kieselbach b32e7e368a Updated deps 2026-06-15 09:32:19 +02:00
Oliver Kieselbach 6732c9189d updated skill 2026-06-13 16:10:14 +02:00
Oliver Kieselbach 3744017c2e feat(agent-v2): phase-driven device-info re-collections (V1 parity)
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>
2026-06-12 19:34:26 +02:00
Oliver Kieselbach f8cba3319f feat(agent-v2): cross-cutting restart dedup for one-shot startup events
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>
2026-06-12 19:33:09 +02:00
Oliver Kieselbach 4d0a76336c perf(mcp): precompute embeddings at image build — boot hydrates vectors from disk
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>
2026-06-12 18:41:58 +02:00
Oliver Kieselbach ea4bb1c8ef fix(agent-v2): persist Office install lifecycle state across agent restarts
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>
2026-06-12 18:16:36 +02:00
Oliver Kieselbach 6529d972cd ci: bump checkout to v5 in SWA workflow too (missed in cb67e8c2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:52:42 +02:00
Oliver Kieselbach cb67e8c2f0 ci: bump actions to node24-ready versions ahead of June 16 forced default
GitHub forces all JS actions onto Node 24 starting 2026-06-16 and
removes Node 20 from runners on 2026-09-16. Bump every affected action
to the first major that officially declares node24, avoiding majors
with functional changes (checkout v6, upload-artifact v7):

  actions/checkout            v4 -> v5
  azure/login                 v2 -> v3
  actions/setup-dotnet        v4 -> v5
  actions/setup-node          v4 -> v5
  actions/upload-artifact     v4 -> v6 (v5 still defaulted to node20)
  softprops/action-gh-release v2 -> v3
  stefanzweifel/git-auto-commit-action v5 -> v7 (v7 reverted the v6
    input removals; only standard inputs used here anyway)

Azure/functions-action@v1 (floating tag, node24 since v1.5.4) and
Azure/static-web-apps-deploy@v1 (Docker action) need no change.
All target tags verified to exist via git ls-remote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:52:01 +02:00
Oliver Kieselbach 25e58be9df fix(mcp): retry model pre-bake with backoff, add PREBAKE_MODEL escape hatch
The CI image build died on a single HuggingFace connect timeout:
transformers.js does not retry its model fetch and undici aborts after
10s, so one flaky connection failed the whole deploy. The prefetch step
now retries up to 5 times with 15s backoff, still fails the build
loudly when the model genuinely cannot be fetched or lands outside
HF_CACHE_DIR, and --build-arg PREBAKE_MODEL=0 skips pre-baking during
an extended HF outage (boot-time download / fuse fallback take over).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:43:36 +02:00
Oliver Kieselbach f4df9de2b9 fix(mcp): search keyword merge, pre-baked embedding model, fuse gating (review H2/H3/M8)
H2: search_events caller keywords are now lowercased and MERGED with
auto-extraction via resolveQueryKeywords() — previously `??` replaced
extraction entirely and case-sensitive keywords like ["BitLocker"]
silently returned zero results.

H3: the ~23 MB MiniLM model is pre-baked at Docker build time (warmup
via the built provider keeps the model name single-sourced) into
HF_CACHE_DIR=/app/.hf-cache and copied into the final stage, so a
scale-to-zero cold start never depends on the HuggingFace CDN; boot
indexing falls back to the fuse backend with a loud log instead of
crash-looping on init failure.

M8: new SearchProvider.semanticCapable flag (vector=true, fuse=false);
selectEventTypeCandidates gates the semantic path on it so inverted
Fuse scores no longer flow through cosine-calibrated thresholds, and
the search_events response now reports the real searchBackend
(keyword-only vs hybrid) plus the provider name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:35:01 +02:00
Oliver Kieselbach ab7bd1afa9 feat(agent-v2): app_install_starved names the app starving the AccountSetup gate (PR3)
The gate-starvation dead-ends (caa6cf50, 1ec8f4c6) were ultimately caused
by a single required user-targeted app that never started installing.
Until now the session just hung anonymously; this makes the starving app
a named, actionable customer hint.

New ImeLogTracker.GetStarvedUserEspApps(): required (Install/Uninstall
intent) apps in the current AccountSetup phase with no download/install
activity ever observed, not terminal, not failed. Apps that are alive
(Downloading/Installing) stay excluded; error apps belong to the failure
path. Same conservative thread-safety contract as AreUserEspAppsSettled.

Two timer-free trigger points, one-shot per appId:
- Live: when the ESP page exits and the user-apps-settled probe reports
  false, EspAndHelloTracker emits app_install_starved (Warning) per
  starved app; dedupe via a lock-guarded HashSet exposed as
  StarvedAppsReported.
- Terminal: EnrollmentTerminationHandler sweeps leftovers on every
  outcome (before app_tracking_summary, skipped on WhiteGlove Part 1) and
  consults the live path dedupe set via the read-model surface, so no
  app is ever reported twice.

Wiring: ImeLogHost bridge -> DefaultComponentFactory probe ->
EspAndHelloTracker; CollectorSurfaces + IAppTrackingReadModel +
OrchestratorAppTrackingReadModel expose the starved list and the
already-reported set to the termination handler.

New event type synced to MCP resource catalog (app_events) and Web
gather-rules eventTypes.

V2.Core 1365/1365, MCP vitest 220, Web 458, full solution build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:53:57 +02:00
Oliver Kieselbach eb3af41a96 feat(decision-core): completion_waiting event with state-change-only fingerprint dedupe (PR2)
When the engine blocks or defers a completion attempt it now says what it
is still waiting on instead of going silent. New Info event
completion_waiting with data.missingPrerequisites built from stable
literals (account_setup_provisioning_complete, hello_resolution,
desktop_arrival, realmjoin_resolution), plus trigger, stage,
armedDeadlines and — on the guard-blocked esp_exiting site — the
AdvisoryCompletion window due-time.

Emission sites: CompleteThroughFinalizingOrDefer gate-closed defer,
HandleDesktopArrivedV1 desktop-first / blocked fast-path,
HandleEspExitingV1 post-AccountSetup guard-blocked exit,
HandleHelloResolvedV1 + HandleHelloSafetyDeadlineFired AwaitingDesktop
branches, HandleAccountSetupProvisioningCompleteV1 no-promote branch.
Pre-AccountSetup handoff exits stay silent (normal flow).

Dedupe via new additive-nullable state fact CompletionWaitingFingerprint
(comma-joined missing-set; ctor-end param, no schema bump — same pattern
as ImeUserSessionCompletedUtc in a77ad21e). A repeat with the same set
emits nothing; a set change emits one new event. SnapshotBuilder facts
16 -> 17; serializer roundtrip + legacy-snapshot compat covered.

New event type synced to MCP resource catalog (esp/enrollment group) and
Web gather-rules eventTypes.

DecisionCore 400/400, V2.Core 1347/1347, MCP vitest 220, Web 458, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:32:55 +02:00
Oliver Kieselbach 7f314b1507 feat(agent-v2): session_parked_without_deadline liveness tripwire (PR1)
The DecisionEngine is edge-triggered: facts get recorded, but nothing
guarantees another signal ever re-asks the completion question — a session
can be parked with no deadline armed. The three known dead-end variants
(8bc1180f, caa6cf50, 1ec8f4c6) are covered by dedicated arming sites; this
adds the tripwire for the unknown variant 4.

DecisionStepProcessor now checks the post-step state: non-terminal, past
AccountSetup entry, dead-end zone entered (ESP final exit at-or-after
AccountSetup entry OR advisory-defanged failure recorded) and NO
resolution-capable deadline armed (ClassifierTick does not count — it
re-arms itself and never resolves a session). One-shot Warning event via
InformationalEventPost (wired through the LazyIngressSinkRelay), no state
schema touch, no auto-action. Expected to never fire in production; every
occurrence is a bug report.

AwaitingDesktop without a deadline fires by design (visible parking); a
DesktopSafety deadline stays a possible later iteration.

New event type synced to the MCP resource catalog (health_events).

V2.Core 1347/1347, DecisionCore 387/387, MCP vitest 220 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:19:35 +02:00
Oliver Kieselbach b0f7e0fcd6 fix(decision-core): arm the completion-resolution window on a guard-blocked post-AccountSetup ESP exit
Session 1ec8f4c6 exposed the third completion-dead-end variant: Windows closed
the ESP page NORMALLY (Shell-Core 62407, errorCode=0) while the AccountSetup
Apps subcategory was still in_progress (a user-targeted app never started).
No EspTerminalFailure ever fired, so the AdvisoryCompletion arming site from
the 8bc1180f fix never ran - yet the dead-end is identical: the registry gate
can never open, the IME settle probe is starved, Hello is disabled, and the
session idles to the max-lifetime watchdog although the full completion
conjunction (real-user desktop + Hello disabled + genuine IME user-session
completion) was satisfied minutes in.

HandleEspExitingV1 now arms the shared AdvisoryCompletion resolution window
when a guard-blocked esp_exiting lands AFTER AccountSetup entry (fire-once;
pre-AccountSetup handoff exits stay arm-free; a later advisory deliberately
re-bases the window). The deadline handler accepts the new anchor (final exit
at-or-after AccountSetup entry) alongside the advisory anchor and fails the
no-evidence case with its own semantics: reason
esp_exit_without_completion_evidence and LastFailureTrigger=DeadlineFired -
NOT esp_terminal_failure, and the likely-stuck app promotion stays off,
because the ESP never reported a failure.

Updated the two tests that pinned "guard-blocked exit arms nothing" to the
new contract - notably the caa6cf50 gate-starvation shape now carries the
resolution window as a 30-min backstop underneath the synthesis fast path.

DecisionCore 387/387, V2.Core 1335/1335 green; solution builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:49:00 +02:00
Oliver Kieselbach e5bebf4bcb fix(backend): map agent_shutting_down(max_lifetime) to a terminal Failed session status
The V2 max-lifetime watchdog is deliberately "notbremse, not a session
verdict": the agent stops permanently and emits only
agent_shutting_down(reason=max_lifetime) - no enrollment_failed. Since the
backend flips session status exclusively on enrollment_complete /
enrollment_failed / esp_failure, such sessions stayed InProgress forever
(session 8bc1180f). V1 agents already emit enrollment_failed at max lifetime;
the gap is V2-only.

Both classification copies (legacy ingest + EventIngestProcessor, kept in
sync per the documented copy contract) now classify the event via a shared
IsMaxLifetimeAgentShutdown predicate and map it as the LOWEST-priority status
writer: any genuine terminal event in the same batch wins via the else-if
chain, already-terminal sessions are protected by the repository idempotency
guard, Pending (WhiteGlove) sessions are skipped, and Stalled sessions
finally resolve to Failed. failureReason carries the uptime;
failureSource=max_lifetime_watchdog.

This is the defense-in-depth half of the 8bc1180f fix - it works for every
field agent immediately on backend deploy, no agent redeploy needed. The
root-cause half (AdvisoryCompletion deadline in DecisionCore) resolves the
dead-end before max-lifetime is ever reached.

Backend tests 2284/2284 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:09:17 +02:00
Oliver Kieselbach 30f71e1b54 fix(agent-v2): roll up harmless ModernDeployment event bursts, exclude them from idle clocks
Session 8bc1180f: Windows dumped 689 EventID-100 "Autopilot policy not found"
records in one minute. Each was downgraded to Debug but still forwarded as an
individual modern_deployment_log event - 701 of 1116 session events (63%),
signal-ingress queue saturated (ingress_backpressure 256/256), and the burst
deferred the periodic-collector idle stop.

Two changes:

- ModernDeploymentTracker: harmless-downgraded EventIds (default 100/1005/1010,
  Level 2/3 -> Debug) are now rollup-gated per EventId - first 3 occurrences
  forwarded individually, afterwards only every 100th, carrying the cumulative
  occurrenceCount (plus a rollup marker in Data). Counter-based, no timers;
  warnings/errors and non-harmless IDs are untouched. The 689-burst now
  produces 9 events instead of 689.
- SignalActivityClassifier: modern_deployment_log added to the non-activity
  denylist - OS-eventlog forwarding is observability, not enrollment progress,
  and a policy-cache re-read burst must not keep the periodic collectors
  alive. modern_deployment_warning/error remain real activity.

V2.Core rollup + classifier tests 33/33 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:00:33 +02:00
Oliver Kieselbach a77ad21e76 fix(decision-core): resolve advisory-defanged ESP failures via AdvisoryCompletion deadline
An advisory-defanged EspTerminalFailure (ContinueAnyway + AccountSetup entered)
deliberately keeps the session non-terminal so the normal completion paths can
still win. Session 8bc1180f showed the dead-end variant: AccountSetup/Apps
failed (0x87d1041c), so AccountSetupProvisioningComplete can never arrive, the
IME settle probe was starved by a never-started user app, and Hello was
disabled - no completion path left. The session idled InProgress until the
max-lifetime watchdog, which by design issues no session verdict.

The advisory path now arms a 30-min AdvisoryCompletion deadline (AgentBoot-
floored, not re-armed by enrichment duplicates). On fire the engine resolves
the session either way:

- Completed (through Finalizing) when the real-user conjunction holds:
  desktop_arrived (DAD-validated, defaultuser0/SYSTEM excluded at the
  detector) AND Hello resolved-or-policy-disabled AND the new
  ImeUserSessionCompletedUtc fact at-or-after AccountSetupEnteredUtc. The
  IME timestamp gate kills defaultuser0 ghosts - OOBE/technician IME sessions
  complete in the pre-AccountSetup frame and can never satisfy it.
- Failed otherwise, un-defanging the advisory: enrollment_failed with
  reason=esp_terminal_failure and LastFailureTrigger=EspTerminalFailure so the
  EnrollmentTerminationHandler likely-stuck app promotion keeps firing.

ImeUserSessionCompletedUtc is an additive nullable DecisionState fact
(set-once in HandleImeUserSessionCompletedV1, serialized snapshot-compatible,
surfaced in the signal census as ime_user_session_completed).

DecisionCore.Tests 381/381 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:55:57 +02:00
Oliver Kieselbach 97397a9836 fix(agent-v2): resolve desktop owner via WTS session query, WMI as fallback
Session 4d5a0b78: WMI Win32_Process.GetOwner failed on every single DAD poll
(wmiErrorCount == pollCount from the first poll on), so the explorer.exe
owner was never resolved, desktop_arrived never fired, and the Desktop half
of the completion AND-gate starved — the session sat in AwaitingDesktop
forever even though the gate-starvation synthesis (d3abbacd) had correctly
opened the AccountSetup gate.

Owner resolution is now WTSQuerySessionInformation(WTSUserName/WTSDomainName)
keyed by the process's session id as the primary path — a single kernel
round-trip with no WinMgmt dependency, and semantically the right question
anyway (explorer.exe runs as the session's logged-on user). The previous WMI
GetOwner path remains as fallback when WTS yields no user.

- New WtsNativeMethods interop (wtsapi32) with buffer-safe QuerySessionString
- DesktopArrivalDetector.ResolveOwner: WTS first, WMI fallback, injectable
  test seams (SessionOwnerResolver/ProcessOwnerResolver)
- first_poll/no_candidate liveness payloads now carry wtsErrorCount alongside
  wmiErrorCount so the broken path is visible per device

V2.Core 1327/1327 green; WTS path live-verified (AzureAD\user shape matches
the existing IsExcludedUser contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:28:06 +02:00
Oliver Kieselbach 4377d35fef feat(web): show RealmJoin packages in Install Progress with RJ: prefix
realmjoin_package_started/completed events now render as first-class
install rows in the session-detail Install Progress, alongside Intune
(app_install_*) and Office (office_install_*) entries. The "RJ: " name
prefix keeps mixed Intune/RealmJoin installs distinguishable and doubles
as the map key, avoiding collisions with IME app names.

RealmJoin has no separate failed event — completed with success="false"
routes to the Failed branch, and lastExitCode maps onto the existing
exit-code badge (error-code catalog lookup included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:17:22 +02:00
Oliver Kieselbach 9e51e439dc feat(web): show RealmJoin agent version in session System panel
Renders productVersion (+ releaseChannel when not "release") from the
existing realmjoin_detected event, right below the IME Agent Version
row. Pure client-side read of the event stream - no agent or backend
change needed, works retroactively for existing sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:15:51 +02:00
Oliver Kieselbach d3abbacd90 fix(agent-v2): complete Classic sessions when a skipped user-ESP app starves the AccountSetup gate
Session caa6cf50: a policy-skipped user-ESP app leaves the ESP registry's
Apps subcategory permanently inProgress, so Windows never writes
AccountSetupCategory.Status.categorySucceeded before tearing down the ESP
page. Both registry-driven AccountSetupProvisioningComplete paths (normal +
all-subcategories fallback) starve, ShouldTransitionToAwaitingHello never
opens, and the session stalls after 60 min despite a successful enrollment
(esp_exiting + desktop_arrived + helloPolicy=false all recorded).

Fix, in three parts:
- ImeLogTracker.AreUserEspAppsSettled(): conservative probe — true only in
  AccountSetup phase with >=1 required app, all required apps terminal
  (Installed/Skipped/Postponed) and zero errors. Exposed via ImeLogHost.
- EspAndHelloTracker: on every Shell-Core exit forward, fire-once synthesis
  of AccountSetupProvisioningComplete when the probe reports settled, plus
  an esp_provisioning_status observability event
  (fallbackReason=esp_exited_user_apps_settled_category_unresolved).
  Wired through a new optional userEspAppsSettledProbe ctor param;
  DefaultComponentFactory connects it to ImeLogHost via closure.
- HandleAccountSetupProvisioningCompleteV1: deferred-completion parity.
  When the strong gate arrives last (Win11 ordering: explorer.exe runs
  under the User-ESP page, so Desktop arrives before the final exit) and
  Hello is resolved or policy-disabled, complete through Finalizing with
  HelloOutcome="Skipped" instead of parking in AwaitingHello until
  HelloSafety stamps a misleading "Timeout" 300 s later.

Sessions with failed or in-flight user apps keep the existing conservative
stall behaviour. DecisionCore 366/366, V2.Core 1321/1321 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:26:36 +02:00
Oliver Kieselbach 0e16704c66 feat(agent-v2): report RealmJoin release channel alongside version
RealmJoin.exe inverts the usual .NET SDK version-resource layout: the
FileVersion STRING carries the full SemVer including the channel tag
("4.21.6-canary+476277.d320cac0") while ProductVersion is the bare
"4.21.6". The previous ProductVersion-first read therefore never saw
the channel.

- RealmJoinInfo: read FileVersion first, parse SemVer into bare version
  + release channel (prerelease tag = channel; untagged = "release",
  confirmed by the RJ developer; build metadata dropped)
- realmjoin_detected signal + timeline event carry releaseChannel
- RealmJoinFacts.ReleaseChannel set-once fact + audit-trail field
  realmjoinReleaseChannel

Suites: DecisionCore 360, V2.Core 1307 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:59:03 +02:00