- New "phase" stage: OnPhaseChanged writes a transition marker line
(phase change: Old -> New) so rule-level lines read against the current
phase; only on real changes, written outside _scopeLock.
- LogParser traces each regex match: line number, matched text (<=200 chars)
and capture groups (<=100 chars, same selection as event data), capped at
10 lines per file per run; the per-file summary keeps the full count.
- Invalid-regex error line now includes the offending pattern string.
Tests: 4 new (transition dedup, match trace, cap, pattern in error);
agent suite 2137/2137 green. OKF doc + docs/log.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetUnmatchedSoftwareFunction runs a full cross-tenant scan of
SoftwareInventory on every request (including every pagination click) but
only reads 9 properties. GetAllSoftwareInventoryAsync now accepts an
optional $select projection and the function passes the consumed columns,
cutting transfer and entity materialization.
Server-side filtering on CpeUri is documented as impossible: rows
re-created by the cascade-delete 404 path carry no CpeUri property and
Table Storage cannot express "property missing" — those rows must still
count as unmatched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AdminConfigProvider no longer eagerly fetches global/config, config/all
(46 KB) and preview/whitelist on every /admin/* entry. Sections that
consume adminConfig/tenants trigger the idempotent ensureAdminConfigLoaded/
ensureTenantsLoaded on mount; fetchTenants stays as the explicit post-save
refresh. Pages that only need the token (Software Mapping, Backups, ...)
now pay nothing.
/admin/software gets a route-level loading.tsx and all three software tabs
render a TableSkeleton instead of a centered spinner on initial load. The
Mapped/Ignored mount guards ("!loaded && !loading") could refetch endlessly
after a failed fetch; now fetch only on !loaded.
Also gitignore .playwright-mcp/ (Playwright MCP browser artifacts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Perceived hangs on navigation (e.g. Distress Reports) had three causes:
missing loading.tsx fallbacks froze the old page until the new chunk
rendered, loading state initialised false flashed empty states for one
paint, and several pages blocked their entire render on the first fetch.
- New TableSkeleton/SectionSkeleton primitives (server-component-safe),
loading-UX convention documented in TableSkeleton.tsx
- 20 new route-level loading.tsx fallbacks (admin areas + top-level pages)
- Distress/Session Reports + Feedback: loading init true, skeleton in the
table slot; refreshes keep the populated table
- Dashboard: skeleton in the sessions slot instead of a blank gap
- Converted full-page loading early returns on audit (also fixes a latent
auth hang: the spinner sat outside ProtectedRoute so a fresh tab never
triggered the MSAL redirect), usage-metrics, fleet-health, sla and
admin Usage Plans; deliberate gates (diagnosis, preview, CalculatingCard
pages) left untouched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hidden-when-unavailable read as missing. The block now always shows:
active checkbox when the session has an uploaded archive, disabled with
an explanatory hint when it does not. Submit semantics unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The report ZIP now captures everything an operator needs even after the
session is gone, and states its own completeness:
- session.csv: append the 15 newer Sessions fields that had drifted out
of the export (failureSource, reconcileReason, adminMarkedAction,
validatedBy, rebootCount, isHybridJoin, isSelfDeployingProfile,
connectionType, geo*, avgApiLatencyMs, apiRequestCount, stalledAt,
failureSnapshotJson)
- completeness signal: exportedEventCount / sessionEventCount /
eventStreamActive in the request and report-metadata.json; the modal
warns while event pages are still streaming
- includedDiagnostics records sourceBlobName/sourceDestination so a
failed copy still points at the original archive
- tenantContext snapshot (fail-soft): upload mode/destination, gather-
rule debug flag, custom gather/analyze rule IDs + UpdatedAt, IME
pattern IDs — no secrets, no definitions
- robustness: 8 MB client-side screenshot cap (20 MB body cap headroom),
attachments status in metadata instead of silently dropping invalid
base64, SanitizeZipEntryName strips path components from customer-
supplied file names (both report types)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Session reports persist while sessions and their diagnostics blobs are
removed by user deletion or retention. A new checkbox in the Report
Session modal (shown only when a diagnostics ZIP was uploaded, default
checked) lets the submitter include the archive: the backend streams a
server-side copy into the durable session-reports container.
- SessionReportDiagnosticsArchiveCopier: fail-soft copy (never blocks the
report), reuses the Hosted/CustomerSas read paths, re-verifies the
hosted tenant prefix via ClassifyBlobName, honours the admin size cap
and download timeout; outcome persisted as DiagnosticsCopyStatus
- Report row/API gain DiagnosticsBlobName + DiagnosticsCopyStatus
(written only when requested, omitted for legacy rows)
- report-metadata.json records includedDiagnostics {requested, blobName, status}
- Admin reports UI: second Download Diagnostics button + failure status line
- Privacy page: explicit exception for the opt-in report copy (Last updated bump)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-poll 404s in dependency telemetry come from clients whose corporate
network blocks WebSockets. Emit the negotiated transport on connect and
reconnect so fallback sessions are attributable instead of mysterious.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MCP spec 2026-07-28 requires clients to validate the iss parameter
against the recorded issuer before redeeming the authorization code
(AS mix-up defense). The /oauth/callback redirect now carries
iss=<public base URL> — the same value the RFC 8414 metadata reports
as issuer — and the metadata advertises
authorization_response_iss_parameter_supported. Pre-9207 clients
ignore unknown response parameters (RFC 6749 s4.1.2), so nothing
regresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Client navigation to an exported route fetches its RSC payload
(<route>/index.txt). The legacy-path wildcards (/admin/backups/* etc.)
matched those .txt paths and served the detail HTML instead — the Next
router cannot parse HTML as a flight payload and falls back to a hard
full-page navigation, resetting all client state (visible as the sidebar
collapsing on every Backups/Customs-Archive navigation).
Exact self-rewrites for all nine exported index.txt files now precede the
wildcards; the swaConfig guard pins txt-before-wildcard for every family.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/sla sat in PUBLIC_PATH_PREFIXES only because SLA notifications
historically linked www…/sla. It is a fully authenticated page
(ProtectedRoute + token fetches), and since ProtectedRoute stands down on
the public host, www/sla waited forever on "Loading SLA metrics...".
Removed from the public prefixes: the nav stays on portal (no bounce),
old www…/sla notification links bounce to portal/sla where auth works,
and SlaBreachEvaluationService.DashboardUrl now links the portal host
directly for future notifications. /sla added to robots disallow.
Web tsc + 603/603, backend build clean + SLA suite 38/38.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SWA's trailingSlash:auto normalizes /apps to /apps/, which the /apps/*
legacy wildcard matches — so the LIST pages were rewritten to the DETAIL
html (prod: /apps rendered the app-detail view with an empty name and hung
on "Aggregating app analytics"). Exact list-route rewrites now precede
each family wildcard; the swaConfig guard pins list-before-wildcard order
for all families.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two causes of the reported "sign in on www, then sign in again on portal":
- Race: on www/<portal-path> ProtectedRoute fired loginRedirect BEFORE
HostRoutingGuard's replace, signing the user in on the wrong origin.
ProtectedRoute now stands down on the public host and lets the guard move
the page.
- prompt "select_account" forced the account picker on EVERY loginRedirect,
including the automatic portal-side re-auth where Entra's session cookie
would complete silently. login({auto:true}) (ProtectedRoute) drops the
prompt; user-clicked logins (LoginButton/PublicSiteNavbar, manual retry)
keep the picker.
Net UX: sign in once — the portal-side re-auth is a silent Entra roundtrip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Production incident after the static-export deploy: authenticated-on-www
users hit a hard ping-pong. Chain: AuthGate (www) pushed /dashboard →
HostRoutingGuard bounced to portal (no MSAL session there — auth state is
per-origin) → ProtectedRoute's login-failure fallback navigated to "/" →
guard bounced back to www → AuthGate pushed again.
Three-part fix:
- HostRoutingGuard: never bounce while the URL carries an MSAL auth
response (#code=/#state= land on the ORIGIN ROOT because redirectUri has
no path — a bounce destroys the sign-in), plus a sessionStorage bounce
counter that stands down after 3 hops/15s as a generic loop breaker.
- ProtectedRoute: login failure no longer navigates to "/" (the loop's
return edge) — fails in place with a manual "Sign in" retry UI.
- AuthGate: on the public host, hand over to the portal origin in ONE
window.location.href navigation (getPortalLoginUrl pattern) instead of
router.push + guard cascade.
tsc clean, vitest 603/603, export build 87 pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ImageResponse metadata routes export extension-less files and SWA
route-level Content-Type headers do not override the file server's type
(verified in prod: application/octet-stream + nosniff, which breaks social
preview crawlers). Replaced with the one-time-rendered PNGs as static
app/ metadata files — Next now emits /opengraph-image.png URLs and SWA
serves image/png by extension. Content-Type route entries dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pinned Azure/static-web-apps-deploy@v1 has no is_static_export input
(warning only — Oryx detected the export and validated out/ regardless).
The real failure was config validation: with "trailingSlash": "auto" SWA
normalizes /x and /x/ to the same route key, so the defensive
trailing-slash duplicates were rejected as duplicate rules. One entry per
route covers both forms under auto.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.
- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
/diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
/admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
consolidated. Suspense boundaries added everywhere useSearchParams drives
page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
the *NavSections registries, dynamicParams=false, client bodies extracted to
SectionClient) — 37 pages; server redirect() index pages became
ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
webhook notifications cannot be edited): SWA wildcard rewrites serve the
family page and the globally mounted LegacyPathRedirect rewrites to the
canonical query URL, preserving query + #event hashes (mapping pinned by
lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
override, all security headers incl. literalized prod CSP (no unsafe-eval),
Content-Type routes for the extension-less og images. Guarded by the new
utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
(query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.
Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-deploy verification of the go endpoint showed every anonymous caller
keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to
the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate,
distress, diag-ticket-download, stats/platform) had silently collapsed
into one shared bucket — confirmed by DistressReports.SourceIp being
"unknown" across the board.
- GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress
(transport-derived, unspoofable; IPv4-mapped addresses normalized).
Heals every existing caller without signature changes.
- New GetRateLimitClientIp for AFD-fronted endpoints: honors
X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID
(FrontDoorProfileId app setting, set on autopilotmonitor-api-eu);
fail-closed to GetTrustedClientIp on unset/mismatch/multi-value.
Used by GetBootstrapScriptFunction (traffic arrives via
go.autopilotmonitor.com, where the trusted hop is AFD egress).
- FdidMatches table tests incl. append/spoof cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The HTTP/1.1 / six-connection-cap claim was wrong — it came from the
Schannel curl build that cannot speak HTTP/2. ALPN (openssl s_client)
and .NET HttpClient confirm the portal negotiates h2; the queue is
server-side in the SWA SSR runtime. Doc, index, log and the
GlobalSidebar comment now describe the real mechanism.
Adds the 2026-07-28 outcome section: Standard plan changed nothing
(stratified p50s match, Mann-Whitney p=0.81); prefetch={false} removed
the freezes (worst totalDuration 225s -> 4.9s) and also removed an
accidental keep-alive (~150ms added to cold navigations).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customer feedback: backfilled modern_deployment_error events (pre-agent
Autopilot event-log errors replayed at agent start) rendered as plain
Error rows with one identical emission timestamp — indistinguishable
from a live failure.
Web (EventTimeline.tsx, effective for already-stored events):
- "⟲ Backfilled" badge (slate, explanatory tooltip) on data.backfilled
- row time now shows the original event-log timeCreated with a
"(reported …)" suffix; metadata block gains a "Recorded" line;
raw mode gets a compact ⟲ marker
- severity deliberately NOT downgraded — unresolved pre-agent errors
must stay visible in the Error filter
Agent (ModernDeploymentTracker):
- backfill scan buffers the capped record set, emits an Info summary
event first (modern_deployment_log, backfillSummary: true +
replayedCount/droppedOverCap/lookbackMinutes) announcing the block,
then replays oldest-first so timeline sequence follows event-log
chronology (cap semantics unchanged: newest 25 win)
No new EventType, no ConfigVersion bump. Tests: summary emission x2,
tracker subset 12/12; tsc + vitest 651/651 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Customers cannot tell why a gather rule produces nothing in the timeline
(e.g. a logparser rule with emit_mode on_change that never emits). New
per-tenant boolean EnableGatherRuleDebugLog (EnableImeMatchLog-style
plumbing end to end): when enabled, the agent writes
%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log with one
pipe-delimited line per evaluation decision across all previously silent
"produced nothing" exits — disabled rules, interval without
intervalSeconds, first-tick-after-one-full-interval, phase-scope skips
incl. currentPhase=Unknown, per-(rule, phase) dedup, empty collector
results (emitOnlyIfExists), on_change hash suppression (hash prefix +
streak), guard blocks, logparser per-file outcomes (position skips,
format=text hint when every line fails CMTrace parsing, maxLines and
wildcard caps), and full exception stacks the agent log drops.
- Backend: ConfigVersion 35 -> 36; TenantConfiguration/AgentConfigResponse
field + TableConfigRepository Store+Map
- Agent: GatherRuleDebugLog writer (10 MB cap, one .old generation,
failures swallowed, optional console echo), GatherRuleContext.DebugLog
(null-safe no-op), trace points in executor + collectors; never writes
under _scopeLock (reasons captured inside, written after release);
merger bool->path projection with --gather-debug-log CLI override
tolerance; deliberately NOT in the offline-cache strip lists;
--run-gather-rules always traces; zero-rules delivery leaves a one-line
file instead of nothing
- Side fix: GatherRuleExecutorHost now passes the logger into
InformationalEventPost so gather-event emissions hit the DEBUG log
- Web: tenant settings toggle (Settings -> Agent) + admin tenant-config
report rows
- Tests: merger on/off/override, writer format+rotation, executor debug
lines, logparser debug lines (agent 2131, backend 3561, tsc green)
- Docs: docs/agent/gather-rule-debug-log.md + index/log entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upload the diagnostics package as a pre-provisioning intermediate when
Part 1 seals (suffix "preprov"). No new config field: the existing mode
semantics carry the desired behaviour — Always uploads at the seal,
OnFailure skips (the seal terminates as Succeeded; a Part-1 failure goes
through the regular Failed terminal path, which already uploads), Off
stays off. The upload runs before the whiteglove_part1_complete emit so
diagnostics_* events keep a lower sequence than the Part-1/Part-2
boundary marker. Best-effort: a fast reseal-reboot just loses the
intermediate package. Backend needs nothing — ingest already stamps
diagnostics_uploaded independent of terminal state (proven live by the
on-demand collection path).
Also make the on-demand path honest: CreateAndUploadAsync now takes
bool? enrollmentSucceeded — null means "no outcome yet" (mid-session),
so the OnFailure gate only skips a KNOWN success and sessioninfo.txt
records "In Progress" instead of the former enrollmentSucceeded:false
workaround that stamped in-flight packages "Failed".
10 new tests (WG seal mode matrix, preprov suffix, event ordering vs the
Part-1 marker, unchanged Part-1 exit contract; nullable-outcome gate
matrix via HTTP-free sentinel, sessioninfo labels). Agent suite
2112/2112 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent already reports connectionType in every network_interface_info
event, but it was only stored as a JSON blob in DeviceSnapshot — answering
"how many machines enroll over Ethernet vs WiFi?" required a slow full-table
scan via deviceProperties. Project it as a typed column instead (template:
the AvgApiLatencyMs chain), making it a cheap indexed one-call query.
- Ingest: TryExtractConnectionType — last network_interface_info emission of
the batch wins (a device switching media mid-enrollment is relabeled);
only exact "WiFi"/"Ethernet" accepted, fire-and-forget fail-soft writer
- Storage: UpdateSessionConnectionTypeAsync (Merge on Sessions + index
mirror, tombstone-safe), BuildSessionIndexEntity superset entry,
MapToSessionSummary, re-registration preserve in StoreSessionAsync
- Search: connectionType= filter with OData eq push-down on the index,
client-side backstop on the non-OData paths, fields=connectionType
projection, pagination fingerprint entry, raw endpoint parity
- MCP: connectionType z.enum param on search_sessions/query_raw_sessions,
fields doc updated (server redeploy required)
- Web: optional sortable/filterable "Connection" column in the session list
- No backfill: legacy sessions lack the column and are excluded by the
filter (intended, same semantics as RebootCount); the deviceProperties
scan path still covers historical data
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derived the language set from actual enrollment geography
(get_geographic_metrics, 365d, 10k sessions across ~50 countries) and
documented the country->language mapping on the key arrays. Newly
covered: Finnish, Hungarian, Lithuanian, Latvian, Romanian, Croatian/
Bosnian, Slovak, Indonesian/Malay, Vietnamese, Thai, Arabic, Bulgarian
radio-type variant and Swedish "Radiotyp". New fi/id parser tests; the
unknown-language tests now use Greek (outside the geography set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the localized label sets with the remaining languages seen in
enrollments: Russian, Japanese, Chinese (Simplified + Traditional,
incl. the 信道/通道 channel variants) and Korean (incl. the two radio-
type spellings). SSID stays untranslated everywhere. New zh-CN and ko
parser tests; suite green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The netsh fallback previously matched English labels only, silently
dropping fields on localized OS images. The parser now matches the
label variants of the languages commonly seen in enrollments
(EN/DE/FR/ES/PT/IT/NL/PL/CS/TR; Scandinavian locales reuse the EN/DE
spellings) and is split into a pure Parse(output) function covered by
unit tests (EN/DE/ES samples, "94 %" spacing, AP-BSSID collision,
unknown-language degradation - SSID itself is untranslated in all
locales). Unknown labels still fall through harmlessly; the native
WLAN API remains the primary path.
V2.Core suite 2095/2095 green; fallback live-verified against netsh
on dev hardware (identical values to the API path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field regression on 2.0.1383 (session f7c56065, HP ProBook 450 G7,
build 26200.8037): the native WLAN API path returned nothing in the
OOBE/SYSTEM context and the null path was silent, so wifi_signal_info
disappeared entirely - while netsh-based 2.0.1380/1382 sessions on the
same build kept reporting SSID/signal the same morning. Suspected (not
yet proven) cause: Wi-Fi location gating of WlanQueryInterface on
24H2+; inbox netsh is unaffected.
- WifiInfoReader: new TryGetCurrentConnection overload reporting WHY
the read yielded nothing (native rc per step, interface state);
callers log it at Debug so the agent log explains missing WiFi info.
- NetshWifiFallback (new): parses netsh wlan show interfaces ONLY when
the WLAN API yields nothing on a WiFi-connected device; SystemPaths.
Netsh restored for it.
- WifiConnectionInfo.SignalPercent is now int? (netsh fallback cannot
always parse localized "Signal"); event payload emits the field only
when present - same shape as before.
V2.Core suite 2085/2085 green; interactive positive path re-verified
(SSID/94%/802.11ax/ch116, empty diagnostics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Narrows the column so more columns fit on one page; matches the
two-line style of Device/Model/Status cells.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
netsh wlan show interfaces output is localized: "Radio type"/"Channel"
(and on some languages even "Signal") never matched on non-English
images, silently dropping fields from wifi_signal_info and network
change snapshots. It also spawned a child process per collection.
Replace both netsh call sites with wlanapi.dll P/Invoke
(WlanQueryInterface: current_connection + channel_number):
- New WlanNativeMethods (raw interop) + WifiInfoReader (fail-soft,
prefers the active NIC's interface GUID, falls back to the first
connected WLAN interface; returns null on machines without WLAN).
- DeviceInfoCollector.CollectWiFiSignalInfo and
NetworkChangeDetector.CollectWiFiInfo now use the reader; RadioType
strings stay netsh-compatible ("802.11ax" ...), event fields
(wifiSsid/wifiSignalPercent/wifiRadioType/wifiChannel) unchanged.
- SystemPaths.Netsh removed (no remaining references).
V2.Core suite 2085/2085 green incl. new WifiInfoReader tests; struct
marshaling verified against native sizeof (604/68/36/532).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Off by default; sortable via avgApiLatencyMs. Data comes from the
SessionsIndex mirror the list already reads — no extra query or table.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fleet data (2026-07-28, agent 2.0.1380) showed the late-start detector was
outcome-blind: tenant 659c3a90 collected 12 Warnings with the post-mortem/
hung-script note on enrollments that succeeded — the bootstrap merely ran
late in the ESP queue and the agent observed the healthy final minutes live.
New pure helper ObservationCoverage.DescribeLateStart:
- Succeeded -> Info with a tail-only-coverage note (IME-replay hint,
bootstrap-ordering review if recurring); no alarm framing.
- Failed/TimedOut -> Warning with the existing post-mortem/hung-script note.
- oobeStateAtAgentStart refines the message only: "completed" = pure
post-mortem, "in_progress" = live tail observed; not_started/unavailable
add no claim (value flips to not_started after mid-OOBE reboots and
"completed" is routine on post-desktop restarts).
The coverage gate (10 min boot-to-start / 5 min uptime) is unchanged, and
the OOBE state is deliberately NOT a gate: the canonical hung-script
failure (f148976f) terminates on the still-shown ESP screen, i.e. with
OOBE reading in_progress.
8 new tests (outcome x oobe-state matrix); V2.Core suite 2067 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- SearchSessionsFunction fields whitelist += avgApiLatencyMs,
apiRequestCount so search_sessions lean sweeps (e.g.
fields=geoCountry,avgApiLatencyMs,apiRequestCount) can aggregate
latency without full SessionSummary payloads
- search_sessions tool doc lists the new fields incl. the weighting
hint; get_geographic_metrics doc names avgApiLatencyMs /
apiLatencyVsGlobalPct as the backend-region distance metric
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The outbound_ip trace event stays available for analysis (timeline,
MCP); only the UI row is removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extra grid cell in the Session Info card forced a fourth row once
Device Validation was present. The latency reading is a "when needed"
detail, so it moves into the collapsed Device Details card as a new
Connectivity section instead — together with values that were collected
but never displayed anywhere: the estimated internet bandwidth
(network_bandwidth_estimate incl. DO-throttle hint), total telemetry
upload volume (agent_metrics_snapshot), and the outbound IP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaces the persisted per-session AvgApiLatencyMs in the geographic
views so operators can compare regions when deciding on new backend
locations (Asia/US):
- ComputeGeographicMetrics: per-location and global request-weighted
latency (sum(avg*requests)/sum(requests)) + ApiLatencyVsGlobalPct;
serves both the tenant and the GA cross-tenant endpoint
- GeoMetricsSessionProjection extended (column-projected scan would
otherwise drop the new fields); equivalence test pins it
- Drilldown: LocationSessionRow carries the per-session values; sessions
table + summary tile show them
- geographic-performance page: sortable "API Latency" column with
absolute color buckets (<250 green .. >=800 red), global benchmark
- PlatformMetricsService: session AvgLatency now exact from cumulative
counters when present (fallback: mean of interval averages)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The V2 agent already measures HTTP round-trip latency per request
(NetworkMetrics, cumulative _totalLatencyMs) but only emitted the
per-interval delta average. Operators deciding on new backend regions
(Asia/US) need the session-wide average, aggregatable by GeoCountry.
Measured baseline: Jakarta sessions average ~600-1050 ms client-side vs
94 ms server-side processing — the gap is pure network distance to GWC.
- Agent: agent_metrics_snapshot now carries cumulative net_total_latency_ms
(total/requests = whole-session average; idempotent by construction)
- Ingest: last matching snapshot of a batch projects AvgApiLatencyMs +
ApiRequestCount onto the Session row (fire-and-forget, fail-soft,
last-write-wins; agents predating the field simply never match)
- SessionsIndex: both fields mirrored (merge + BuildSessionIndexEntity
superset) so list/GA views can aggregate latency per geo
- Web: session detail shows "API Latency" when present
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET metrics/ime-versions is MemberRead and the archive is one global
partition, so every tenant member reads the same platform-wide rows. The only
thing between an ordinary tenant user and another tenant's identifiers was an
inline projection with no test behind it.
There is no HTTP-level harness in this suite - nothing constructs an
HttpRequestData with a RequestContext - so the projection was unreachable from
a test while it lived inside Run. Extracted it to a static
BuildResponsePayload(versions, hasGlobalScope), matching the pattern
GetTpmPssUnsupportedFunction already uses for the same reason. Behaviour is
unchanged: the payload is returned as `object`, and System.Text.Json resolves
an `object` declared type via the runtime type, so both branches keep the wire
format they had inline. A test asserts that rather than trusting it.
10 tests, asserting on serialized JSON in camelCase (Program.cs's naming
policy) because a property dropped from an anonymous type is only a real
redaction once it is absent from the wire:
- non-global: firstSeenTenantId / firstSeenSessionId absent, and their GUID
VALUES absent anywhere in the payload (a model rename must not let the
identifier survive under another key)
- non-global: exactly {version, firstSeenAt, lastSeenAt, sessionCount}
- non-global: sessionCount survives - the counts are platform-wide by
decision, pinned so it cannot drift either way unnoticed
- global (Admin or Reader): full entry including both identifiers
- both scopes: array shape, empty archive, row order preserved
Mutation-checked both directions: forcing the global branch fails 3 tests,
dropping sessionCount from the projection fails 2. Full suite 3521 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worker serializer omits null fields (WhenWritingNull), so the
aggregated global/metrics/device-journeys response carries no
repeatDevices at all. The strict `=== null` check missed undefined and
fell through to repeatDevices.length, crashing the page. Verified
against the deployed chunk: crash column 11464 is the `0===o.length`
read behind the `null===o` guard.
Loose `== null` check + optional DTO field; absent and null both render
the "select a tenant" hint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Defender for Cloud reports CVEs against the mcp container image that the
repo scanners cannot see. The subassessment evidence paths split them in two:
/usr/local/lib/node_modules/npm/** - the npm 10.9.8 that node:22-slim
bundles, vendoring @sigstore/core 2.0.0, brace-expansion 2.0.2, tar 7.5.11
(critical), sigstore, picomatch, ip-address, plus npm itself (high, no fix
available). Present in all 20 images including the current one. Dependabot
reads package-lock.json, so none of this is visible to it.
/app/node_modules/** - body-parser 2.2.2, fast-uri 3.1.2, hono, protobufjs,
tar 7.5.16, all confined to images from 2026-07-21 and earlier and already
fixed by 7df208fa/c3e9621a. Those findings age out with the old ACR tags.
npm is build-time only here: CMD is `node dist/index.js`, the HEALTHCHECK is
`node -e`, the container app spec overrides no command and probes over HTTP,
and src/ contains no child_process call. So remove npm rather than upgrade it
- npm 11.x still vendors @sigstore/core 3.1.0, itself inside the vulnerable
<= 3.2.0 range, and would clear nothing.
Separately, tar 7.5.20 -> 7.5.22 in our own tree (GHSA-r292-9mhp-454m, via
@huggingface/transformers -> onnxruntime-node). 7.5.22 satisfies the declared
^7.0.1, so no override is needed. fast-uri deliberately stays on 3.1.4: it is
patched against all four fast-uri advisories, and ajv 8.18.0 declares ^3.0.1,
so forcing 4.x would be an override against a major range.
npm audit --omit=dev: 0 vulnerabilities. tsc clean, 455 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With ASP.NET Core integration the HttpRequestData body is a
forward-only stream whose Length property throws NotSupportedException
(verified in prod: 500 + System.NotSupportedException on the first
POST). The size cap is now enforced by reading at most cap+1 chars via
ReadBlockAsync instead of asking the stream for its length.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New anonymous POST ops/alert-webhook receives Common Alert Schema
notifications from an Action Group webhook receiver and re-emits them
as ops events (category Platform, type AzureMonitorAlert), so Azure
Monitor alerts flow through the existing OpsEventService ->
OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal
ops-events list) like every internally raised operational event. The
delivery path is independent of the portal: a SWA outage cannot
suppress the notification; the action group's e-mail receiver stays as
the backend-independent fallback.
Security: action-group webhooks cannot send custom headers, so the
shared secret travels as ?secret= and is compared in constant time
(FixedTimeEquals) against the OpsAlertWebhookSecret app setting -
fail-closed while unset. 64 KB payload cap, defensive essentials-only
parsing (only alertRule is mandatory), severity mapped Sev0->Critical,
Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling
back to Info so schema drift can never escalate.
Dual-registered per convention: EndpointAccessPolicyCatalog entry
(PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule
wiring) and Platform category in OpsEventsSection (badge + filter).
Backend suite 3511 green (incl. new helper tests + catalog
completeness), web tsc + vitest 651 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>