Commit Graph

1282 Commits

Author SHA1 Message Date
Oliver Kieselbach b4529d2c71 feat(mcp): get_resource tool + description tightening
Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless
mode) that don't expose `resources/list` reliably. Verified with a paired
in-memory client that the server itself advertises `capabilities.resources`
and answers `resources/list` correctly — the breakage is on the client
side. Tools work everywhere, so a tool-call gateway is the pragmatic fix.

Resources now share a single source-of-truth (`resource-catalog.ts`) used
by both the MCP-protocol resource handlers and the new `get_resource(name)`
tool. Tool descriptions previously said "see the device_properties resource"
— updated to "call get_resource(name=...)" so the hint actually leads
somewhere usable.

Also tightens get_rule_stats description: the default window × number of
rules × per-day trend can easily exceed the response cap, so the doc now
recommends passing a tighter date range or ruleType filter for routine
queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:40:01 +02:00
Oliver Kieselbach 629c12eb55 feat(mcp): role-aware routing — GA → /api/global/*, tenant-admin → /api/*
Fixes the silent confusion where a Global Admin who passed `tenantId=X`
got routed to the tenant-scoped endpoint, which used the GA's own JWT
tenant (home), not the requested one. The user saw their home tenant's
data instead of X — no error, just wrong data.

Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into
an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick
the route via a small helper:

  pickGlobalOrTenantPath('/api/global/X', '/api/X')

  GA           → /api/global/X (cross-tenant; tenantId is a filter param)
  Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict)

Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each
backend pair was verified to exist (both `/api/X` and `/api/global/X`
have a Function defined).

Also drops the MCP-side filter pass in get_session_events — server-side
filtering was added in the previous commit so count and nextLink are
now coherent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:27 +02:00
Oliver Kieselbach 3116a0ecc9 feat(backend,web): geographic-sessions default-lean (?full=1 opt-out)
The full LocationSessionRow shape is ~40 fields per session — for
locations with dozens of sessions the response easily blew past 100KB
and tripped MCP token caps. Most fields aren't needed for the typical
"which sessions enrolled from here" triage flow.

Default is now a 17-field lean projection (sessionId, device basics,
status/duration, geo, app/DO summary). UI keeps the full shape via
?full=1 — single-line addition in lib/api.ts.

Also adds an optional tenantId filter on /api/global/metrics/geographic/
sessions so a GA can scope a single tenant's sessions for a location
without leaving the global endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:31:39 +02:00
Oliver Kieselbach 84a1aa14cb feat(backend): server-side filters for /api/sessions/{id}/events
Push eventType/severity/source filters from MCP-tool layer down into the
Function so count and nextLink stay coherent. Previously filters lived
client-side: backend returned the full page, MCP filtered → could
produce count: 0 with nextLink set, which read as a bug ("nextLink is
emitted even though there's nothing left").

With server-side filters:
- count reflects post-filter items in this page
- nextLink is still gated on NextRawToken (more raw rows ahead in this
  session — filter may or may not match further down)
- filter values echo back onto nextLink so callers can follow pagination
  without having to re-supply them

SessionEventsPagination.BuildNextLink learns an optional `extras` arg so
arbitrary query params survive the round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:27:49 +02:00
Oliver Kieselbach b9997809ed feat(backend): tenantId filter on global audit/logs + ops-events
Both endpoints already supported optional date-window + pagination but
ignored a tenantId query param. With the upcoming MCP role-aware routing
GA needs /api/global/* endpoints to filter to a single tenant while
keeping cross-tenant capability when the param is absent.

- audit/logs: routes to existing tenant-scoped IMaintenanceRepository
  methods when tenantId is set (cheap PK lookup); fingerprint scope
  widened to "audit:global:tenant:<id>" so cross-token reuse fails.
- ops-events: OpsEvents are partitioned by category, not tenant — the
  filter is applied post-fetch. Pages may report fewer items than
  pageSize when the filter is narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:52 +02:00
Oliver Kieselbach fae4d5eb2f feat(backend): /api/auth/mcp returns isGlobalAdmin
The MCP server needs to know the caller's GA status to make routing
decisions (GA → /api/global/* with tenantId filter; non-GA → /api/*
JWT-bound). Previously the access-check response only carried
allowed/reason/accessGrant — the MCP layer had no way to detect role.

McpUserService now resolves IsGlobalAdmin unconditionally (it's needed
for both whitelist and AllMembers paths), and the result rides through
on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays
backward-compatible via default-param.

Prep for the upcoming MCP role-aware routing refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:22:40 +02:00
Oliver Kieselbach 3fe519d839 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-05-07 16:18:37 +02:00
Oliver Kieselbach 5292cd2dfe fix submit diag sidebar entry 2026-05-07 16:17:52 +02:00
Oliver Kieselbach 089c0123e6 feat(reports): add Submit Logs page for session-less diag-files reports
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/
.json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from
/settings/tenant/support without needing a session context — no more
OneDrive round-trips for ad-hoc client-log requests.

Backend
- New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table
  + session-reports container, distinguished by ReportType="diagFiles".
- ReportType discriminator added to SessionReportMetadata + Store/Map
  (legacy rows default to "session").
- Telegram + Global-Admin bell notifications mirror the session-report flow.

Frontend
- New "Submit Logs" tenant settings tab with comment / email / log+state
  files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate.
- Admin SessionReportsSection now shows a Type column (Session / Diag Files)
  and hides Session ID in the detail modal for diag-files rows.

Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc
--noEmit clean, 259 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:02:55 +02:00
Oliver Kieselbach 93152d86ec fix whiteglove split 2026-05-07 15:02:52 +02:00
Oliver Kieselbach d4d828d8c6 feat(web): in-flight request collapser (dedupedAuthFetch) + 2 callsites
Adds a drop-in wrapper around authenticatedFetch that collapses concurrent
GET/HEAD requests to the same URL into a single underlying network round-
trip. Each caller receives an independently readable Response clone.

This is a request-collapser, NOT a cache. Entries live only as long as
the underlying Promise is unresolved; once it settles, the entry is
removed on the next microtask. No TTL, no eviction, no staleness — by
design, since SignalR push-truth would race against any pull-cache. Calls
that merely happen to hit the same URL after a response has arrived go
fresh.

Mutations (POST/PUT/PATCH/DELETE) bypass the collapser unconditionally
so write semantics are unchanged.

Migrated two known-duplicating callsites:
- useTenantSecurityConfig (dashboard) — feature-flags GET that re-fired
  on the user=null → UserInfo transition.
- useSessionTenantConfig (session detail) — feature-flags GET that
  could overlap with other consumers on the same render burst.

Belt-and-suspenders: the prior dep-stabilization + !user guard on
useTenantSecurityConfig stays in place; the collapser is defense-in-
depth so future regressions of the same shape are caught generically.

8 new vitest cases cover: concurrent collapse, independent body reads,
no caching across microtask boundaries, different URLs not collapsed,
mutations bypass, rejection propagation + slot release, HEAD collapsing,
and GET vs HEAD keyed separately. 257/257 web tests, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:17:59 +02:00
Oliver Kieselbach 22427e24c5 perf(web,backend): finish off the duplicate-fetch + response-header fixes
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:

- Timing-Allow-Origin was still missing on every API response (curl
  confirmed) and X-Correlation-ID was missing too — a preexisting bug.
  Both middlewares used httpContext.Response.OnStarting() to attach the
  header, but that callback is not reliably triggered in the .NET 8
  isolated worker: the host bridges the worker's response to the wire,
  so OnStarting fires on a shadow object that never reaches the client.
  UserRateLimitMiddleware already writes X-RateLimit-* directly on
  Response.Headers before calling next(), and those headers do come
  through in production. Switching both header-emitting middlewares
  to that pattern.

- /dashboard kept firing /api/config/<t>/feature-flags twice. The
  earlier dep-stabilization on useTenantSecurityConfig was incomplete:
  the effect fires once on initial render with user=null (deps
  [tenantId, undefined, undefined, undefined]) and once after AuthContext
  resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
  tuples, two fetches. The user=null path also bypassed the role gate
  (the guard was `if (user && ...) return`). Adding `!user` to the
  early-return at the top of the effect collapses to one fetch and
  also drops the would-be 401 we were producing during the loading
  window.

Backend tests 1058/1058, web vitest 249/249, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:01:09 +02:00
Oliver Kieselbach 0fc81f99ed perf(web,backend): cut dashboard load time + fix duplicate fetches
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:

- AuthContext: store the auth/me prefetch as a Promise instead of its
  resolved value so fetchUserInfo awaits the in-flight request rather
  than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
  role primitives instead of the whole user object so the effect stops
  refiring when AuthContext swaps prefetched user for fresh user (the
  duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
  startConnection. The original "avoid competing with critical API
  calls" rationale doesn't apply across origins (web is HTTP/2, API is
  a different host); the delay only pushed live updates ~2s into the
  load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
  remaining four Table reads (role, tenant config, sessions existence,
  feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
  from ~1285ms (6 sequential reads on Flex Consumption) toward the
  slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
  function response so the portal's Resource Timing API can read API
  payload sizes (transferSize / nextHopProtocol) for our own RUM —
  cross-origin entries currently report 0/unknown.

Backend tests 1058/1058, Web tsc clean, vitest 249/249.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:52 +02:00
Oliver Kieselbach b61e327752 added full width button 2026-05-07 12:19:11 +02:00
Oliver Kieselbach b91b6c6d52 fix(backend): push AgentVersion(Prefix) filter server-side in search_sessions scan
The paged scan path (`SearchSessionsByScanPageAsync`) routed AgentVersion +
AgentVersionPrefix through `MatchesScanClientFilters` — applied AFTER the
SessionsIndex page was already fetched. For sparse subsets like "all V2
enrollments" (~1% of sessions) this meant the LLM had to follow 10-30 nextLink
roundtrips of mostly count:0 pages while the index walked past the dense
non-V2 majority.

Move both filters into `BuildSearchScanFilter` as native OData clauses, same
trick `DeviceName` and `OsBuild` already use:

  AgentVersion eq 'X'                                       (exact)
  AgentVersion ge 'X' and AgentVersion lt 'X~'              (prefix)

Drop the now-redundant client-side check in `MatchesScanClientFilters`. The
device-snapshot path (`SearchSessionsByDeviceSnapshotPageAsync`) keeps the
client-side filter in `ApplyBasicFilters` because DeviceSnapshot doesn't carry
AgentVersion as a column.

Result: search_sessions(agentVersionPrefix="2.0.") becomes a single-call
operation for typical tenants instead of a roundtrip marathon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:01:31 +02:00
Oliver Kieselbach 00f3bf61ef feat(mcp,backend): server-driven response cap + version prefix + lean projection
Real-world test exposed two distinct LLM-context bottlenecks the pagination
rollout alone didn't solve:

  1. Anthropic's MCP client truncates tool responses at ~25-30k chars by
     default, mid-page; nextLink follows but rows in the truncated middle are
     silently lost. pageSize alone can't fix this — even pageSize=50 of full
     SessionSummary objects (~1.5KB each) blows the cap.

  2. Aggregation queries ("how many V2 enrollments?") shouldn't need 1.5MB of
     SessionSummary blobs paginated across 4 round-trips. The LLM only needs a
     handful of fields to count.

Three coordinated fixes, all server-driven so customers connecting via MCP
get them automatically without any client config:

* `_meta.anthropic/maxResultSizeChars` annotation on every tool response,
  calibrated per response density (sessions=300k, events=250k, rawTable=500k,
  small=50k). The Anthropic client respects it and stops mid-page truncation.
  New `toolResultText()` helper in shared.ts wraps the pattern.

* `fields=` projection on `search_sessions` and `query_raw_sessions` (server-
  side, prunes wire payload 5-20x) and on `query_table` (client-side, since
  TableEntity columns are dynamic). Always retains PartitionKey + RowKey for
  cursor stability. Tool descriptions now actively recommend it for counting /
  aggregation queries.

* `agentVersionPrefix=` / `imeAgentVersionPrefix=` filters on `search_sessions`
  and `query_raw_sessions`. Replaces the "one call per build" loop pattern when
  the LLM wants a whole version line. Mutually exclusive with the exact-match
  variants (exact wins). Fingerprint-bound so a token from "2.0." can't replay
  into "2.1.".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:31:50 +02:00
Oliver Kieselbach d4464619b5 fix span param 2026-05-07 11:04:10 +02:00
Oliver Kieselbach abf71f7c14 chore(mcp): bump hono / ip-address / express-rate-limit via npm audit fix
Clears 3 moderate Dependabot alerts (GHSA-9vqf-7f2p-gf9v, GHSA-69xw-7hcm-h432,
GHSA-v2v4-37r5-5v8g) — all transitive via @modelcontextprotocol/sdk and
semver-compatible patches:
  hono              4.12.15 -> 4.12.18
  ip-address        10.1.0  -> 10.2.0
  express-rate-limit 8.3.1  -> 8.5.1

The vulnerabilities are not on hot paths in our code (MCP uses express not
hono; ip-address only for rate-limit key derivation, never HTML emission), but
keeping the lockfile clean removes a recurring noise source on PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:59:58 +02:00
Oliver Kieselbach 861159873e added width query param to session list 2026-05-07 10:58:49 +02:00
Oliver Kieselbach 840ac9f21f feat(backend,mcp): paginate the last three legacy MCP diagnostic tools
query_raw_sessions, search_sessions_by_cve, and query_table were still on the
legacy `?limit=` contract (capped at 200/500/100 with no nextLink). On large
deployments this made "all devices affected by CVE-X" or "every row in
TenantConfiguration" structurally un-answerable.

Migrate to the established pageSize/continuation/nextLink contract:

- query_raw_sessions reuses SearchSessionsPagination; field projection logic
  unchanged.
- search_sessions_by_cve gets a new SearchSessionsByCvePageAsync repo method
  (analog to by-event) plus SearchSessionsByCvePagination helper with
  cveId+CVSS/risk fingerprint binding.
- query_table gets a generic RawTablePagination helper covering tableName +
  PK/RK/filter scope.

Sweep all pageable MCP tools to use followNextLink so backend-echoed query
params round-trip correctly. Raise session-list defaults 50 to 200 and unify
tool descriptions on a "pagination-first" message: pageSize is the batch size,
not a truncation point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:55:39 +02:00
Oliver Kieselbach 08107d7b68 fixed scripts v2 leftovers 2026-05-07 10:17:35 +02:00
Oliver Kieselbach d8e1eb9292 Changed bootstrap script and V2 agent binary name (now without V2, preperation for V1 replacement) 2026-05-07 10:12:37 +02:00
Oliver Kieselbach c337970143 fix audit fetching 2026-05-07 09:58:53 +02:00
Oliver Kieselbach 939bc2ebc7 fixed platform metrics, ops and session reports fetching 2026-05-07 09:14:37 +02:00
Oliver Kieselbach e7e501d8cd added audit filter for unnecessary entries 2026-05-07 08:39:04 +02:00
Oliver Kieselbach 5a44a9e495 fix(web): advance dashboard pagination on first Next click
Driving loadMore() from the auto-load effect in page.tsx left a one-render
gap where loadingMore was still false, so the snap-back effect in
useDashboardFilters fired first and reverted currentPage from 2 back to
totalPages=1 before the fetch even started. The fetch then ran (the
auto-load effect saw the captured currentPage=2 from that render) and the
list grew to totalPages=2, but currentPage had already been snapped to 1
— giving the visible "Page 1 of 2+" state and forcing the user to click
Next a second time to actually see the new batch.

Now handleNextPage calls loadMore() in the same click handler as
setCurrentPage(prev+1), so React 18's automatic batching commits both
state changes in the same render. Snap-back's `if (loadingMore) return`
guard then correctly skips, currentPage stays at the overshoot, and once
the fetch resolves totalPages catches up and the new batch renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:29:14 +02:00
Oliver Kieselbach 9b4100f7d6 fix(web): unify dashboard pagination + bump fleet-health page size
Dashboard: removed the standalone "Load 100 more sessions..." button. The
session table now has a single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions —
the existing auto-load effect in page.tsx already drives loadMore() once
currentPage * sessionsPerPage exceeds sessions.length, but handleNextPage
was clamping to totalPages so currentPage could never overshoot, leaving
"Next" disabled until the user clicked the separate Load-More button.

  - useDashboardFilters now takes hasMore + loadingMore; handleNextPage
    advances past totalPages when hasMore && !loadingMore. A snap-back
    effect resets currentPage to totalPages once a load finishes that
    didn't grow totalPages (filter case: new batch yielded zero matches).
  - SessionTable renders the Prev/Next controls whenever there's somewhere
    to navigate (totalPages > 1 || hasMore), shows "Page X of Y+" while
    more is on the server, disables Next only at the true end, and shows
    an inline spinner inside Next during loadingMore. The empty tbody
    distinguishes "loading sessions..." from "no results".

Fleet Health: bumped FLEET_PAGE_SIZE from 200 to 1000 (backend MaxPageSize
is 1000). A 90-day window on a busy install now drains in ~1/5 the
round-trips, eliminating the 5x roundtrip-amplification users observed
on initial load.

Type-check clean, vitest 249 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:15:37 +02:00
Oliver Kieselbach 70382e991b feat(backend,web,mcp,shared): MS-Graph nextLink pagination rollout
Lands the planned 6-PR pagination rollout (project_mcp_pagination_rollout)
as one tested batch covering session_events, audit+ops, session_reports,
search_sessions/list, and query_raw_events. Backend builds clean; Backend
1024 / Agent V2 1007 / Web 249 tests green.

Primitives (Shared/Pagination):
- ContinuationToken with fingerprint binding (tenantId, dateFrom/To, filters)
  so a forged or cross-tenant token fails validation on the next page.
- PaginatedResult<T> + RawPage<TEntity> as the canonical wire shapes.

Backend (AutopilotMonitor.Functions/Pagination):
- AzureTablesPaginator drives single-partition + per-partition pages.
- DateWindowPagination sweeps day-buckets for global audit/ops queries
  (the backend resolves "last 30d" once and echoes dateFrom/dateTo on
  nextLink so follow-up calls don't recompute "now" and break the token).
- PerPartitionFanOutMerge stable-merges N partition cursors for cross-
  tenant session search.
- Per-feature plans (SessionEventsPagination, SearchSessionsPagination,
  SearchSessionsByEventPagination, SessionListPagination,
  SessionReportsPagination, QueryRawEventsPagination).
- Functions adopt nextLink: GetSessionEvents, GetAuditLogs (tenant +
  global), GetOpsEvents, GetSessionReports, SearchSessions,
  SearchSessionsByEvent, GetSessions, GetAllSessions, QueryRawEvents.
- Repository contracts (ISessionRepository, IOpsEventRepository,
  INotificationRepository, IMaintenanceRepository) gain *PageAsync
  variants returning PaginatedResult; legacy GetSessionsAsync becomes
  a drain-all helper used by maintenance/feedback/SLA paths that still
  need bounded full-list semantics. SessionPage DTO removed.
- PlatformMetricsService now drains all date-bounded sessions via
  GetAllSessionsAsync (previous maxResults:500 was a silent truncation).

Web:
- lib/paginationLink.ts + ?? api.ts wrappers expose follow-nextLink in
  hooks (useSessionEvents, useDashboardSessions) and pages (audit,
  fleet-health, session detail, OpsEvents, SessionReports admin).
- Session list uses Pattern B2 (load-more append); audit + ops + reports
  use Pattern B1 (click-next replace); session detail timeline keeps
  Pattern A (eager-fetch full set).

MCP:
- followNextLink helper accepts either an opaque continuation OR the
  whole "/api/?" path the backend emitted, and follows the latter
  verbatim so server-resolved query params (tenantId fingerprint,
  resolved dateFrom/dateTo, page size) round-trip correctly. Without
  this, get_audit_logs / get_ops_events / list_session_reports / sessions
  follow-ups would silently fail token validation.
- get_audit_logs, get_ops_events, list_session_reports, and the session
  tools migrate to pageSize + continuation/nextLink, with tool docs
  spelling out the round-trip rule so external MCP clients pass the
  full nextLink instead of stripping it down to "continuation".

Tests:
- New: ContinuationTokenTests, DateWindowPaginationTests,
  PerPartitionFanOutMergeTests, AuditLogRowKeyOrderingTests,
  SessionEventsPaginationTests, SessionListPaginationTests,
  SearchSessionsPaginationTests, SearchSessionsByEventPaginationTests,
  SessionReportsPaginationTests, QueryRawEventsPaginationTests, and
  TestSetup helpers.
- Web: paginationLink.test.ts + sessionListLoadMore.test.ts.
- PlatformMetricsServiceWindowTests adjusted for the drain-all change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:53:46 +02:00
Oliver Kieselbach 9a511bac29 fix(web): exempt static assets and well-known files from middleware
The previous matcher ran middleware on /.well-known/microsoft-identity-association.json,
BingSiteAuth.xml and other public/ assets at the root, which on www would
have 302-redirected them to portal — breaking publisher-domain
verification fetches and host-bound webmaster files.

Excluding any path containing a dot is the standard Next.js pattern: app
routes and route handlers have no extension, so they still flow through
middleware; static assets and well-known files are served directly by
the host they're requested on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:44:17 +02:00
Oliver Kieselbach 576ea02b92 feat(web,backend): public/portal split via host-based middleware
Adds host-based routing middleware so www.autopilotmonitor.com keeps the
public/marketing surface (SEO-indexed) and portal.autopilotmonitor.com
hosts the authenticated app. Apex is handled by Strato HTTP forwarder
(301 to www, path-preserved); the apex branch in middleware is
defense-in-depth for direct DNS hits.

Sign-In on the public host now hands off cross-origin to portal so MSAL
fires on the right origin and the token lands in portal's sessionStorage
directly. This avoids a second login round-trip after the post-auth
redirect, which would not be silent given prompt:"select_account".
ProtectedRoute now triggers MSAL login itself for never-authed users so
the cross-origin nav lands cleanly on /dashboard.

Backend deeplinks (session notifications, Teams alert) now point at
portal. Bootstrap URL switches to the apex shortform
(autopilotmonitor.com/go/{code}) since Strato 301-redirects to www
preserving the path. PowerShell bootstrap output points users at portal
for progress.

SEO metadata, sitemap, robots, OG and SLA/docs links remain on www.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:37:07 +02:00
Oliver Kieselbach a35726dcf0 fix(web): split WhiteGlove timeline at Part-2 agent_started, not at whiteglove_resumed
The post-reseal agent_started + its agent_version_check are part of the
User Enrollment block in the user's mental model, but the resumed-marker-based
split kept them inside Part 1 because whiteglove_resumed is emitted ~0-2s
after the boot. Walk back from whiteglove_resumed to the agent_started that
sits between Part-1 close and resume, and split there. Falls back to the
resumed marker when the boot event isn't present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:23:23 +02:00
Oliver Kieselbach b86af63055 changed msal to use window.location.origin 2026-05-06 12:36:59 +02:00
Oliver Kieselbach b4e98e32eb fix(agent): add MS-Organization-Access cert fallback to TenantIdResolver
Adds EntraDeviceCertHelper that reads the AAD-Join device certificate's
TenantId extension (OID 1.2.840.113556.1.5.284.5) from LocalMachine\My.
Wired as the 5th and final synchronous probe in TenantIdResolver, after
the four registry probes:

  1. enrollments_registry         (Type=6, Intune MDM)
  2. enrollments_registry_fallback (any Type, AADTenantID present)
  3. cloud_domain_join_tenant_info
  4. cloud_domain_join_join_info
  5. ms_organization_access_cert  <- new

Targets the Autopilot pre-provisioning / hybrid window where the cert
can land before the CloudDomainJoin registry writes complete. Lets the
agent boot without falling through to the registry-watcher awaiter and
burning the 600s wait window.

Helper hardening over the original draft:
- Filters to currently-valid certs (NotBefore <= now <= NotAfter) and
  picks latest NotAfter when multiple match, defending against an
  expired sibling from a prior re-enrollment.
- If the highest-priority cert lacks the OID extension, walks through
  the remaining candidates instead of bailing on the first miss.
- Logger emits Info on hit (thumbprint + NotAfter) and Warning on miss
  with the specific reason (no cert / extension missing / parse failed).

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:19:02 +02:00
Oliver Kieselbach b67d68c552 fix(agent): TenantIdResolver fallback for non-Type-6 enrollments + UPN redaction
Two-pass scan of HKLM\SOFTWARE\Microsoft\Enrollments: pass 1 keeps the
existing Type=6 (Intune MDM) preference, pass 2 falls back to any
non-Type-6 sub-key carrying a non-empty AADTenantID. Targets the
Autopilot pre-provisioning / hybrid window where Type=6 sits at
EnrollmentState=1 with AADTenantID still missing while a Type=26
AAD-Join entry already carries the TenantID. Observed at customer
where the agent burned ~30min waiting for CloudDomainJoin\TenantInfo
instead of resolving immediately from Type=26.

Captures the enrollment UPN and classifies it. Known Autopilot
pre-provisioning placeholders (fooUser@... Intune-MDM leg,
autopilot@... Entra-Join leg, both generated by the Windows
MakeFakeUserEmail code path) are logged in full with annotation;
anything else is treated as a real user UPN and redacted to
<redacted_real_user_upn> since UPNs are PII.

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:13:48 +02:00
Oliver Kieselbach 6935ca8e00 fix(backend,web): expose member-readable tenant flags via feature-flags endpoint
Three frontend hooks fetched /api/config/{tenantId} from non-admin contexts
(dashboard banner, session detail UI flags, gather-rules validation indicator)
and silently 403'd for Operator and Viewer because the endpoint is gated
TenantAdminOrGA — same anti-pattern as the tenant notifications bell.

The full /api/config/{tenantId} response cannot be relaxed because it carries
admin-only data (SAS URLs, webhook URLs). Instead, the already-MemberRead
/api/config/{tenantId}/feature-flags endpoint is extended with the five UI
display flags those hooks actually need:

- validateAutopilotDevice (Autopilot Device Validation banner)
- showScriptOutput (session PowerShell output visibility)
- enableSoftwareInventoryAnalyzer (session analyzer flag)
- enableIntegrityBypassAnalyzer (session analyzer flag)
- unrestrictedMode (gather-rules validation indicator)

Nullable model fields are flattened to their agent-side defaults so the UI
does not need a second nullable layer. The projection lives in a static
BuildPayload helper for unit-testability.

Frontend hooks switched from api.config.tenant() to api.config.featureFlags();
gather-rules GA-override path keeps using globalConfig.tenant (GA-only).

Tests: 4 new xUnit cases including a sentinel that fails if SAS URLs or
webhook URLs ever leak into the payload. Backend 886/886 green, web 234/234.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:18:47 +02:00
Oliver Kieselbach 9b0a2dca95 fix(backend,web): role-gated tenant notifications + audience visibility
Tenant-bell endpoint silently 403'd every 60s for Operator/Viewer because the
GET was gated TenantAdminOrGA but the navbar shows the bell to every member.
Three real bugs were tangled in this:

- SLA breach + consecutive-failures notifications were emitted into the global
  channel even though they are per-tenant data. Only Global Admins ever saw
  them; the actual tenant got no in-app indication.
- Frontend polled /api/notifications for any authenticated user regardless of
  role and silently swallowed the 403 — masking the underlying mis-gate.
- Hardware-rejection notifications would have leaked admin-only context (link
  to whitelist editor) once the policy relaxed.

Fixes:

- New TenantNotificationAudienceCatalog (Type to Member/Admin map) drives
  per-type visibility server-side. Unknown types fail closed to Admin so a
  newly added type does not leak to non-admin members until registered.
- GET /api/notifications relaxed from TenantAdminOrGA to MemberRead. Dismiss
  endpoints stay TenantAdminOrGA (tenant-shared dismiss; per-user dismiss
  deferred with TODO + memory note).
- SLA breach + consecutive-failures emitters moved to TenantNotificationService.
  GlobalNotificationService injection removed from SlaBreachEvaluationService
  entirely (we do not use it anymore, so it is gone).
- Frontend polls only when user has a tenant role or is Global Admin; dismiss
  buttons hidden for Operator/Viewer.

Tests: 6 new xUnit, 7 new vitest. Backend 873/873 green, web 234/234 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:35:43 +02:00
Oliver Kieselbach 5ef6c8b1d2 fix(mcp): MCP server security & capability audit (Phases A-E)
Critical: GET /api/metrics/mcp-usage/user/{userId} (TenantAdminOrGA, no
TenantScoping) handed the oid to the repo without checking that returned
records belong to the caller's tenant. A tenant admin from tenant A could
read MCP usage records of any oid — including oids in tenant B.

McpUsageMetricsFunction.GetUserUsage now blocks 403 CrossTenantAccessDenied
when records are tenant-foreign and caller is not a Global Admin. The pure
decision logic lives in UsageCrossTenantGuard for unit-testability (9 tests
covering own/foreign/mixed/GA/empty/null/case-insensitive/empty-tid paths).

Also:
- MCP get_api_usage tool was calling /api/metrics/mcp-usage/{userId}
  (without /user/), which is unregistered and returned 404 — corrected.
- list_blocked_devices description honestly states the endpoint is
  GA-only on both variants (catalog policy, no change to the gate).
- days clamp [1, 365] added to /metrics/{app, geographic, geographic/sessions}
  for parity with /metrics/summary, /metrics/usage, /metrics/platform.
- get_geographic_metrics, get_geographic_sessions zod schemas now constrain
  days to int().min(1).max(365) — same pattern as the other tools.
- search/sessions, search/sessions-by-event, search/sessions-by-cve switched
  limit handling from Math.Min(lim, 100) to Math.Clamp(lim, 1, 100); the old
  form let a negative ?limit=-5 reach the repo.

Audit static analysis confirmed all other tenant-scoped endpoints route
tenantId through TenantHelper.GetTenantId(req) (JWT-only, ignores query),
so ?tenantId=<other> is silently dropped. Live-verified with an Operator
token: 14 cross-tenant query probes returned only the caller's tenant,
13 GlobalAdminOnly routes returned 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:12:54 +02:00
Oliver Kieselbach feba2a2551 fix(backend): materialize CurrentPhase=Failed on terminal failed status
V2 emits enrollment_failed with Phase=Unknown (per feedback_phase_strategy),
so currentPhase.HasValue was false in UpdateSessionStatusAsync — leaving
Sessions.CurrentPhase at -1 on Failed sessions even though decisionState
already knew the last observed phase. UI PhaseTimeline could not render a
Failed marker. Mirror the existing Succeeded => Complete override in both
the normal and the post-ETag-exhaustion force-write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:42:20 +02:00
Oliver Kieselbach 0687612a96 fix(v2): preserve source timestamps + boot-anchor deadline math
Replay-safety fix for two related defects that surface when the agent
reads accumulated CMTrace / Shell-Core log content on first boot
(per-enrollment lifecycle, fresh install).

PR1 — IME adapter (SignalAdapters/ImeLogTrackerAdapter.cs)
  Every emit site (EspPhaseChanged, AppState, UserSessionCompleted,
  WhiteGloveSealing, ImeAgentVersion, DoTelemetry, ScriptCompleted)
  now stamps DecisionSignal.OccurredAtUtc and the InformationalEvent
  with the CMTrace log entry timestamp the tracker captures into
  LastMatchedLogTimestamp. Falls back to clock when no source time is
  available; tags the event with derivedTimestamp=true on fallback.
  Pathological clamp: source TS older than 24h or in the future >1h
  is rejected, original preserved as rejectedSourceTimestamp.

PR2 — Shell-Core backfill + EspAndHelloTrackerAdapter
  ShellCoreTracker.HandleBackfillRecord now propagates record.TimeCreated
  via a new LastEventOccurredAtUtc property on the tracker; the
  EspAndHelloTracker coordinator mirrors this property in its event
  forwarders. EspAndHelloTrackerAdapter reads the coordinator value at
  every Post site (HelloResolved, FinalizingSetup, WhiteGloveSuccess,
  EspTerminalFailure, DeviceSetupComplete, HelloPolicyDetected) so
  backfilled events carry source time, not "agent woke up just now".

PR3 — DecisionState.AgentBootUtc + boot-anchor guard
  Without this guard, PR1+PR2 are a footgun: replayed signals carry
  historical timestamps, and the reducer sites that arm deadlines as
  signal.OccurredAtUtc + window would produce due-times in the past,
  collapsing the Hello-safety window, device-only ESP detection,
  finalizing grace, and classifier-tick to immediate fires at boot.

  Added DecisionState.AgentBootUtc + DecisionEngine.EffectiveDeadlineBase
  helper that floors the arming base at the boot anchor. Applied at all
  six reducer sites (HelloSafety, DeviceOnlyEspDetection, FinalizingGrace,
  ClassifierTick @ SessionStarted + AttachWG + re-arm).

  EnrollmentOrchestrator.Start() stamps AgentBootUtc from the live IClock
  on initial state and re-stamps it on snapshot rehydration so the prior
  run's anchor doesn't bleed into deadline math after restart. Backward
  compat: legacy snapshots without the field deserialize with null and
  fall back to signal.OccurredAtUtc — pre-fix behavior preserved.

Tests: 287/287 DecisionCore + 1007/1007 V2.Core + 862/862 Functions +
530/530 Agent.Core. New: BootAnchorDeadlineFloorTests (5) +
ImeLogTrackerAdapterSourceTimestampTests (6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:24:24 +02:00
Oliver Kieselbach 84610f9fb0 refactor(v2): unify signal census between final-status.json and audit trail
The on-disk final-status.json and the on-the-wire enrollment_complete audit
trail both project the same DecisionState into a "which signals were observed"
view, but did so via two independent code paths that drifted: AAD device-only
joins were dropped entirely from final-status.json, ShellCore + IME pattern
strings differed in spelling, and account_setup_entered was missing client-side.

Extract the census into a shared DecisionStateSignalCensus helper in DecisionCore
so adding a new milestone signal lights it up in both surfaces automatically.
Final-status.json gains a top-level imeMatchedPatternId mirror so the IME pattern
id survives the format switch from "ime_pattern:{id}" to "ime_pattern_matched".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:57:50 +02:00
Oliver Kieselbach 41e47191f3 test(v2): lock .part1-<utc> archive bucket inclusion in diag package
The recursive AgentState walk in DiagnosticsPackageService already captures
StateArchiver's `.part1-<utc>/` buckets on WG Part-2 boots, but no test
asserts it. A future refactor that flips includeSubfolders to false or
trims StateFilePatterns would silently drop the Part-1 forensic snapshot
from the diagnostics ZIP.

Adds a lock-test that creates the four bucket files (snapshot/signal-log/
journal/reason) and asserts they round-trip under AgentState/.part1-…/.
Also extends the Section 4 comment to name `.part1-<utc>/` alongside
`.quarantine` as the second intended subfolder consumer of the recursion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:13:23 +02:00
Oliver Kieselbach d16b60f14c feat(v2): WG Part-2 architectural cleanup + install-mode handoff fix
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.

## WG Part-2 architectural cleanup (PR-A through PR-E)

Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.

The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).

V2 is now V1-symmetric:

  PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
  persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
  reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
  first-boot through the normal recovery branches, and posts a single
  whiteglove_resumed InformationalEvent after the onIngressReady hook.
  New helper class StateArchiver.cs + StateArchiverTests +
  EnrollmentOrchestratorPart2EmissionTests.

  PR-B — DecisionCore Cleanup. Removed:
    - DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
      DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
    - SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
    - SessionOutcome.WhiteGlovePart2Complete
    - DeadlineNames.WhiteGlovePart2Safety
    - DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
    - WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
    - DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
    - HandleSessionRecoveredV1 dispatcher + handler

  PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
  from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
  HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
  AadJoinHost is the only production caller using a named-arg form,
  unaffected.

  PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
  TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
  (IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
  the dead `UserAadSignInComplete` constant from Shared.Constants.
  Extracted pure helper computeWhiteGloveSplitSequence(events) in
  app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
  utils/sessionExportUtils now share it. New 6-case vitest covering
  splitter behaviour (no marker, primary trigger via whiteglove_resumed,
  agent_started fallback, Pending-only variants, primary-vs-fallback
  race). PhaseTimeline + eventTypes were already on the new model.

  PR-E — Tests + Fixtures Final Cleanup. Deleted the two
  whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
  removed dead `whiteglove_part2` case + test from SummaryDialog
  OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
  removed symbol names so the final grep is clean. Updated MEMORY
  entries (project_v2_completion_location, project_wg_part2_simple_model,
  project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
  MEMORY.md index).

What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.

Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.

## Resume-path correctness fixes (Codex review of the cleanup)

Three findings on the new resume mechanic:

  P1a — AgentRuntimeHost was still skipping LifecycleEmitters
  PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
  After PR-A's archive-and-reset the decision state is empty, so the
  Classic flow needs both anchors (otherwise ScenarioProfile stays at
  default and the Hello+Desktop AND-gate misclassifies). Fixed: post
  unconditionally for facts; SessionStarted is now only gated by an
  AdminAction, not by the resume flag. The orchestrator's
  whiteglove_resumed lands first (smaller sequence) so the Web splitter
  (`splitSequence = resumed.sequence - 1`) still puts SessionStarted
  into the Part-2 timeline block.

  P1b — StateArchiver was archiving event-sequence.json along with the
  reducer state. Part 2 reuses the same SessionId and the backend orders
  events session-wide by (SessionId, Sequence); the Web split also uses
  `splitSequence = resumed.sequence - 1`. Resetting the counter at the
  resume boundary would collide Part-1 and Part-2 sequences and break
  the splitter. event-sequence.json is now intentionally preserved; the
  three reducer-state segments still archive. StateArchiverTests +
  RecoveryPathTests asserting the new contract.

  P2 — V1 reset the local session-age clock on Part-2 resume
  (SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
  V2 was clearing only the WhiteGlove marker, so a crash/reboot during
  Part-2 AccountSetup would let the next start run the emergency-break
  watchdog against the original Part-1 timestamp and trip too early.
  AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
  (DateTime.UtcNow) right after ClearWhiteGloveComplete. New
  ProgramGuardsTests test asserting the V1-symmetric resume contract.

## Install-mode handoff hardening

Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.

  PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
  install-time runtime handoff. schtasks /Run reports SUCCESS as soon
  as the run is queued; the same battery / AV / OOBE-defer paths that
  motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
  task stuck in Queued state). WMI also gives the runtime process the
  same isolation Task Scheduler uses, without the Job-Object inheritance
  risk of a direct Process.Start.

  PR2 — Register the BootTrigger Scheduled Task from a hardened XML
  definition rather than `schtasks /Create`. The CLI form inherits XML
  schema defaults DisallowStartIfOnBatteries=true and
  StopIfGoingOnBatteries=true, which queue the BootTrigger run
  indefinitely on a laptop that boots on battery in OOBE / WinPE. The
  XML disables both, sets StartWhenAvailable, and pins
  ExecutionTimeLimit=PT0S so the long-running agent is never killed at
  the schtasks 72h CLI default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:36:37 +02:00
Oliver Kieselbach c0b7b50457 fix(v2): PR-X1 — skip summary dialog + enrollment_summary_shown on WG Part 1
WhiteGlove Part 1 sealing has no end-user session and no completed app
installs, so launching the summary dialog there either lands invisibly
in SYSTEM context (about to be reseal-rebooted) or fails outright; the
enrollment_summary_shown event was semantically wrong and polluting the
timeline. Gate RunBuildAndLaunchDialog (and the state==null warn-only
branch) behind !isWhiteGlovePart1; Completed / Failed /
WhiteGloveCompletedPart2 paths unchanged.

Plan reference: bdb3cf9d-wg-part1-part2-followups.md PR-X1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:25:35 +02:00
Oliver Kieselbach ce97a583d0 fix(v2): propagate AadJoinedWithUser + rename aad_user_joined_late→_observed
AadJoinWatcherAdapter never wrote SignalPayloadKeys.AadJoinedWithUser into the
decision-signal payload, so HandleAadUserJoinedLateV1 always read withUser=false.
Every Classic AAD-Join session ended up with aad_user_joined_device_only in the
audit trail and a wrongly-flipped WhiteGloveSealingClassifier weight. The watcher
only fires AadUserJoined for non-placeholder accounts (placeholders go via the
separate PlaceholderUserDetected event), so the flag is unconditionally true at
emit time.

Cosmetic: rename the wire EventType from aad_user_joined_late to
aad_user_joined_observed. The "late" qualifier reflected the agent's observation
perspective ("I missed the live event"), which holds for 100% of Classic sessions
and reads as misleading in the timeline (the event lands seconds after agent
start, before aad_join_status). FailureSnapshotBuilder accepts both strings — V1
agents and historical data still carry the legacy name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:37:39 +02:00
Oliver Kieselbach 11838e1719 feat(web): PR4 — Lifecycle Anchors tab in Inspector
Plan §A + §B (Zero-Periodicity Observability, 2026-05-03). Render the per-
anchor DecisionState snapshots that PR2 wrote into Event.Data, with the
Death-Rattle dual-snapshot special case that PR3 produces.

- New "Lifecycle Anchors" tab in /sessions/{id}/inspector — one row per
  event carrying data.decisionState, oldest-first, filterable by event
  type. Each row expands into a DecisionStateCard showing stage /
  stepIndex / lastAppliedSignalOrdinal / scenario block / facts table
  with {value, ordinal} per fact / activeDeadlines / forwards-compat
  "Other fields" bucket.

- Death-Rattle special case: prior_run_died_with_state events render
  TWO cards side-by-side — "Prior run state (at death)" (priorState)
  on top, "Fresh run state (post-recovery)" (decisionState) below —
  plus a metadata strip with previousExitType, lastBootUtc, and the
  optional crashException. The pairing is the load-bearing diagnostic:
  delta between the two reads as the recovery delta.

- FailureSnapshotBlock on the session-detail page now links into the
  Inspector at ?tab=anchors so operators reading a 5h-timeout snapshot
  can jump straight to the anchor stream. SearchParam-driven tab
  initialisation in inspector/page.tsx (single read on mount; clicks
  stay client-state, no history churn).

- Schema-driven anchor filter: an event is an "anchor" iff
  data.decisionState is a non-null object — mirrors what the agent
  emitted exactly even if the C# allowlist evolves between agent
  versions. 13 vitest cases pin the predicate (Death-Rattle present
  and Death-Rattle missing-decisionState; primitive / null / undefined
  decisionState; sequence sort with fail-soft for missing field).

- Inspector remains behind requireGlobalAdmin (M6 gate) — no new
  permission surface.

Test stand: vitest 221 passed (15 files, +13 cases); tsc --noEmit clean.
2026-05-03 21:51:57 +02:00
Oliver Kieselbach 118b4c0a41 test(v2): extract DeathRattlePrelude so PR3 gate is directly testable
Codex review on PR3: the AgentRuntimeHost integration (gate + path build +
pre-Start read) is the fragile part of the plan and was only tested
indirectly through the helper + emitter paths. A regress like
"TryReadRaw moved to after Start", "WhiteGlove resume not skipped" or
"clean exit fires" would have slipped through.

Pull the gate / path / read sequence into a dedicated DeathRattlePrelude
in V2.Core so it can be exercised in isolation:

- DeathRattlePrelude.TryCapture(stateDirectory, previousExitType,
  isWhiteGloveResume, logger) returns the prior snapshot or null;
  IsUncleanExit exposed for callers that want to log the decision.
- AgentRuntimeHost reduced to a single TryCapture call; private
  IsUncleanExit + System.IO using removed.
- DeathRattlePreludeTests (24 cases) cover:
    - Clean-exit table (clean / first_run / unknown / null) — all skip,
      even with snapshot present.
    - Unclean-exit table (reboot_kill / hard_kill / exception_crash,
      mixed casing) — all read.
    - WhiteGlove Part-2 resume gate wins over unclean exit.
    - Snapshot missing / corrupt → null, never throws.
    - Path-layout pin: prelude reads the same hardcoded "snapshot.json"
      filename the orchestrator writes to.
    - Non-mutation contract: byte-level equality before/after, file
      still loadable via SnapshotPersistence.Load(), no .quarantine
      directory created (orchestrator owns the quarantine decision).
    - Argument validation (null logger / null+empty stateDirectory).

Test stand: V2.Core 983 (+24).
2026-05-03 21:40:10 +02:00
Oliver Kieselbach 6e240fba53 feat(v2): PR3 — Death-Rattle (prior_run_died_with_state)
Plan §B (Zero-Periodicity Observability, 2026-05-03). On the next agent run
after an unclean exit (reboot_kill / hard_kill / exception_crash), read the
prior run's last persisted snapshot BEFORE orchestrator.Start() and emit a
single prior_run_died_with_state event in the start-callback so post-mortem
diagnosis sees what the dying run last knew — without relying on client-side
log uploads.

Coupling with PR2 (Plan §A): the event itself is on the lifecycle-anchor
allowlist, so EventTimelineEmitter automatically attaches the FRESH run's
data["decisionState"] alongside the data["priorState"] we set here. Both
views (persisted-at-death vs post-recovery reconstructed engine state)
land in one wire payload.

Skip conditions: WhiteGlove Part-2 resume (planned exit, not a death),
clean / first_run exits, snapshot missing or corrupt (TryReadRaw returns
null on every failure mode, agent keeps running).

Changes:
- SnapshotPersistence: extracted TryReadInternal as shared parser; added
  public static TryReadRaw(path) — quarantine-free static reader the agent
  can call before Start without disturbing the orchestrator's recovery
  pipeline. Failure-equivalence with Load() pinned by reader-parity test.
- LifecycleEmitters.PostPriorRunDiedWithState: builds Warning + immediate-
  upload event with previousExitType (defensive null→"unknown" fallback),
  lastBootUtc, optional crashExceptionType, and priorState (Plan §A
  snapshot dict). Swallows ingress exceptions to keep the agent_started
  → version_check chain robust.
- AgentRuntimeHost: pre-Start TryReadRaw block + IsUncleanExit gate +
  in-callback PostPriorRunDiedWithState immediately after the existing
  agent_started / version_check / unrestricted-mode anchors.
- Tests: 9 SnapshotPersistence (TryReadRaw + reader-parity + no-quarantine
  contract); 8 LifecycleEmittersPriorRunDied (event shape, wire policy,
  priorState schema, crash-exception inclusion, null-ExitType fallback,
  message contents, ingress-exception swallow).

Test stand: V2.Core 959 (+17), DecisionCore 293, Backend 862, V1 530.
2026-05-03 21:06:39 +02:00
Oliver Kieselbach 7fe0e9ef58 feat(v2): PR2 — wire DecisionState snapshot into EventTimelineEmitter
Plan §A enrichment (Edge-Triggered State Snapshots, 2026-05-03). For
events on the lifecycle-anchor allowlist, EventTimelineEmitter.Emit
now attaches the current DecisionState snapshot under data["decisionState"]
after ResolveData(). Existing Data fields are always preserved — both
the parameter-rebuilt path and the typedPayload-merged path mutate a
fresh dict, so adding the new key is a strict merge, not a replace.

Behavior:
- 13 anchor event types get decisionState (full allowlist from PR1's
  LifecycleAnchorEventTypes).
- Non-anchor events (enrollment_complete/_failed, app_install_*,
  performance_snapshot, download_progress) are unchanged.
- Idempotent: an existing data["decisionState"] (defensive — should
  never occur in practice since only the emitter writes that key) is
  not clobbered.

Why Emitter, not Reducer (per Codex rev2 review):
- EffectRunner.cs:114 already passes stateAfterReduce to the emitter,
  so no new dependency is needed.
- Reducer-side enrichment via typedPayload would have replaced existing
  Data via the typedPayload-as-dict branch in ResolveData — wiping
  espPhase, patternId, network state etc. The emitter-seam is the only
  place that runs after ResolveData and sees a dict ready to merge into.

Tests (V2.Core +22 tests, suite 920 → 942):
- All 13 anchor event types via Theory: each gets decisionState with
  schemaVersion matching the builder's const.
- Parameter-based path: esp_phase_changed preserves reason+espPhase.
- typedPayload path: network_state_change preserves from/to/transition.
- Idempotence: prebuilt sentinel decisionState in typedPayload survives.
- Non-anchor exclusion: 5 representative non-anchors confirmed null.
- Top-level allowlist sanity-check (delegates full coverage to PR1's
  DecisionStateSnapshotBuilderTests).

Tests (regression-clean across all suites):
- V2.Core: 920 → 942 (+22)
- DecisionCore: 293 (no change)
- Backend Functions: 862 (no change)
- Existing EventTimelineEmitterTests: 26/26 still pass.

Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7).
PR3 (Death-Rattle / prior_run_died_with_state) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:41:06 +02:00
Oliver Kieselbach a98f9d5725 feat(v2): PR1 — DecisionStateSnapshotBuilder + lifecycle-anchor allowlist
Foundation for Plan A (Edge-Triggered State Snapshots) and Plan B
(Death-Rattle / prior-run attestation). Add-only — no behavioral change,
no events emitted yet. PR2 wires the emitter, PR3 adds death-rattle.

Constants (Shared.EventTypes — 5 new):
- AgentStarted, SystemRebootDetected, PerformanceCollectorStopped,
  AgentMetricsCollectorStopped, PriorRunDiedWithState
- String-literals at V2 emit-sites refactored to use the constants:
  LifecycleEmitters, PeriodicCollectorLifecycleHost, DecisionEngine.Edge

DecisionCore (new):
- DecisionStateSnapshotBuilder: public const string SchemaVersion
  "decision-state-snapshot-v1"; Build(state) returns
  Dictionary<string, object?> with explicit top-level allowlist
  (schemaVersion, stepIndex, lastAppliedSignalOrdinal, stage, outcome,
  facts, scenario, activeDeadlines). All 15 SignalFact<T> properties
  serialized as { value, ordinal } for evidence trace; null for unset.
  Excluded: SessionId/TenantId (PII), AppInstallFacts /
  ScenarioObservations / ClassifierOutcomes (too large).
- LifecycleAnchorEventTypes: HashSet of 13 anchor event types behind
  Contains() wrapper. netstandard2.0-compatible (no IReadOnlySet).

Tests (DecisionCore +41 tests):
- SchemaVersion-Const-Check (against const, not literal)
- Top-level allowlist + excluded-fields enforcement
- Per-SignalFact value+ordinal preservation (DateTime, string, bool, enum)
- Reflection-coverage scoped to SignalFact<T> properties with camelCase
  key pinning — catches future SignalFact additions automatically
- Newtonsoft JSON round-trip (null facts emit as JSON null)
- Allowlist membership (13 anchors), non-anchor exclusion (6),
  null/empty/unknown handling, anchor-count assertion

Tests: DecisionCore 252 → 293 (+41), V2.Core 920, Backend 862,
V1 Agent.Core 530 (regression-clean).

Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7,
PR-FREIGEGEBEN nach 7 Codex-Review-Pässen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:35:26 +02:00
Oliver Kieselbach fb31a2d6d1 feat(v2): close Hybrid User-Driven completion gaps
Session e58bcfdb stuck 5h on Hybrid User-Driven enrollment because
the foouser OOBE shell triggered desktop_arrived prematurely and no
Hello/AAD-real-user signals ever followed.

Agent (V2):
- DesktopArrivalDetector excludes foouser/autopilot placeholders
  (UPN prefix + exact bare username; autopilotadmin etc. stay through)
- AadJoinWatcherAdapter dual-emits placeholder + real-user join as
  informational events for Events-table consumers
- DesktopArrivalDetector resets on placeholder->real-user transition
  via AadJoinHost.onRealUserJoined wiring
- New single-shot HybridLoginPendingDetector (10m, gated on
  reboot_kill + isHybridJoin, cancels on real-user join)

Backend:
- New FailureSnapshotBuilder writes per-session diagnostic snapshot
  on 5h-timeout (last ESP phase, desktop arrival, Hello policy,
  AAD-join state, missing canonical signals); aad_user_join slot
  satisfied by either Part 1 or Part 2 join event
- Sessions.FailureSnapshotJson column with Store/Map round-trip
- UpdateSessionStatusAsync extended with optional snapshot param

Web:
- New collapsible Failure Snapshot block in SessionInfoCard

Tests: V2.Core 920, Backend 862, DecisionCore 252, V1.Core 530
(regression-clean), Web vitest 208.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:56:10 +02:00