The violator table on /sla linked to /session/${tenantId}/${sessionId},
which 404s — the canonical route is /sessions/[sessionId] (single
segment, plural). Same convention as /diagnosis, /admin and the
inspector. Restores navigation and makes sla_violator_opened actually
fire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User typed a tenant filter without clicking Submit, then clicked Next →
fetchSessionsBatch read the live tenantIdFilterRef and built a URL with the
typed filter, but the continuation token in state was issued before the typing
under a different filter scope. Backend recomputed fingerprint, mismatched →
400 filter_mismatch.
Track the submitted filter in a separate ref, updated only via
refetch/refetchWith/initial-fetch/global-mode-toggle. The live ref keeps
driving client-side display via effectiveSessions; backend pagination always
queries under the scope its continuation was issued for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H1 OAuth redirect_uri host allowlist (loopback always; hosted clients
via MCP_ALLOWED_REDIRECT_HOSTS, default Anthropic/OpenAI/Google).
Closes auth-code-leak through unvalidated /oauth/callback.
M1 followNextLink rejects continuation paths that don't equal the
tool's own basePath - closes cross-tool path substitution.
M2 strict UUID schema on every sessionId input - blocks WHATWG-URL
path-normalization attacks via /api/sessions/{id}.
M3 rate-limit moved after access-check (Step 1) - forged JWTs cannot
drain a real user's per-minute budget.
L1 get_rule_stats uses pickGlobalOrTenantPath like every other tool.
L3 5xx response bodies no longer relayed to the caller; structured
4xx errors keep their detail.
L4 hardcoded CLIENT_ID fallback removed - module throws on boot if
AUTOPILOT_ENTRA_CLIENT_ID is unset.
Adds security-guards.test.ts (32 unit tests) covering all guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-test post-deploy of #60 surfaced two minor follow-ups:
1. get_ops_events MCP tool: backend already accepts tenantId filter
(since C2), but the MCP tool schema didn't expose tenantId so Zod
silently dropped any caller-passed value. Adds tenantId to the input
schema and forwards it in the query. 2 LOC.
2. /api/global/metrics/summary: the global handler was hardcoded to
pass null (all tenants) and ignored ?tenantId=X. Adds the same
optional filter pattern used elsewhere — null when absent, scoped
when present. The tenant-scoped /api/metrics/summary route is
unchanged (JWT-tid still authoritative).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Submit Logs (tenant settings) and Report Session modal both relabel the
email field so users understand it's the channel we may reply on; the
Submit Logs card also gains mx-auto so it sits centered in the wide
tenant-settings layout instead of left-aligned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The summary used to embed every key event's full data/dataJson payload —
one busy session could push the response well past 80 KB and trip the
response cap. The point of the summary is triage, not raw payload
delivery; users wanting full event data already have get_session_events.
Now:
* key events sorted by relevance (Errors > phase transitions > Warnings
> others), capped at 50 entries; full timeline still drives stats
* the displayed slice is re-sorted chronologically for readability
* heavy `details` field dropped — was the main bloat source
* stats.keyEventsTotal + keyEventsTruncated exposed so callers know if
more events were filtered out
Description updated to set expectations + point at get_session_events for
full payloads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>