Compare commits

...

64 Commits

Author SHA1 Message Date
Oliver Kieselbach 0d6c03d64b Public texts no longer name a customer domain or IME decompiles
- Two code comments of the gather-rule timeline feed named a customer's domain next to the
  session id; the session id stays as the regression anchor.
- The get_ime_pattern_health description and the IME Pattern Health admin text no longer send
  the reader to "the IME decompile"; the IME-TOKEN-FAILURE and IME-DO-TEL pattern descriptions
  say "verified against IME x" / "absent from the x builds" (rules/dist regenerated).
- Code comments that cited decompiled IME builds cite the IME version instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:00:52 +02:00
Oliver Kieselbach b14866c9a6 bump pointer: internal (gather-rule state committed, B-hx3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:46:44 +02:00
Oliver Kieselbach 25b133d3d0 bump pointer: internal (gather-rule state across restarts, D-309)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:45:29 +02:00
Oliver Kieselbach 684167292f Gather rules keep their place across agent restarts; byte-exact logparser reader; MCP filters by gather event types
- Logparser positions per (rule, file) and the on_change values persist in
  State\gather-rule-state.json, bound to the session id. A reboot, hard kill or WhiteGlove
  Part 2 no longer replays a log from byte 0 or re-emits an unchanged on_change result. A
  replaced log (fingerprint of its first 4 KB) is read from the beginning. Positions are saved
  after the events went out; restoring the file is fail-soft. --run-gather-rules stays in memory.
- LogLineReader: a maxLines stop loses no line, an unfinished last line is never split
  (interval rules wait one run, one-shot triggers read it again next run), UTF-16/UTF-32 logs
  are read by their byte order mark from any position, line ends as StreamReader.ReadLine,
  logLineNumber counts from the start of the file, lines over 32 MB are skipped. A maxLines
  value that is no positive number falls back to 1000.
- UserProfileResolver no longer caches "no user signed in yet" (detection retried at most
  every 30 s); guarded callers expand the token with the profile they checked.
- MCP get_session_events, search_sessions_by_event and query_raw_events accept a gather
  rule's own outputEventType; an empty first-page result for an uncatalogued type carries
  eventTypeNote with the closest built-in types instead of being rejected up front.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:45:10 +02:00
Oliver Kieselbach 6474860a57 bump pointer: internal (lesson from the vulnerabilities feedback)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:15:56 +02:00
Oliver Kieselbach d23f2eae3e bump pointer: internal (vulnerabilities feedback fixes, D-308)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:14:40 +02:00
Oliver Kieselbach fae6aa0b92 Vulnerabilities band filters always find entries, list controls stay put, first load shows the progress dialog
The summary endpoint takes perBand: above 0 it adds bandCves and bandSoftware, each severity and
priority band's top entries outside the lists. The portal asks for 10 per band and shows them only
under a filter, so Attend lists its 7 CVEs instead of nothing; the MCP tool never asks and keeps its
size. The Most exposed controls sit below the title in every state, bands with 0 entries are
disabled, and the first load shows CalculatingDialog (the Geographic Performance dialog without its
page) with separate estimates for all tenants and one tenant. D-308.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:13:47 +02:00
Oliver Kieselbach 43dfb731f1 bump pointer: internal (vulnerabilities tab redesign, D-307)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 19:38:45 +02:00
Oliver Kieselbach dd5534b0c9 Vulnerabilities tab shows a severity donut, the priority ladder and the most exposed software
The exposure summary groups CVE index rows by product: every new row carries the finding's
CPE vendor:product as Product, and a row without it borrows the product of a row with the
same title. The summary adds topSoftware, distinctSoftware and kevAffectedSessions, and
known-exploited CVEs and products stay listed below the topN cut (capped so the MCP result
stays under its 50k cap). The shared panel is rebuilt: KPI tiles, a severity donut and the
Act/Attend/Track ladder as list filters, and a Most exposed card with Software and CVEs
lenses. get_vulnerability_summary describes the new fields. D-307.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 19:35:09 +02:00
Oliver Kieselbach 54334de378 Rule pages point to AI-assisted rule authoring
The community box on the gather and analyze rule pages gets a line that an
assistant connected via MCP can draft, validate and test a rule, linking the
AI-assisted rule authoring docs page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 19:11:56 +02:00
Oliver Kieselbach 9671191eef bump pointer: internal (/plans nav marking, slash links, MCP docs, D-306)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 18:43:41 +02:00
Oliver Kieselbach c5a4feb762 Internal links on the public pages end with a slash
The host answers /plans, /about, /privacy and the other page paths with a
301 to the slash path first. The navbar, the footer, the landing and
get-started calls to action, the terms page and two portal links to
/settings now point at the slash path directly. A guard renders every
public page with the build's trailing-slash setting and fails on an
internal link without it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 18:41:22 +02:00
Oliver Kieselbach 2253fca533 bump pointers: internal, .claude/commands (AI page and marketing click counts, D-305)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 18:16:42 +02:00
Oliver Kieselbach ff9e79dcc7 Landing gets an AI page (/ai) and counts clicks and section views
New public page /ai, linked from the navbar (AI), the Then just ask section
and the footer. It shows what an AI client can answer from the structured
enrollment data: a question gallery whose answer panel switches between chat
app, VS Code, terminal and self-hosted, the data path (where the model runs,
what reaches us) and how to connect. A test keeps every MCP tool and argument
on the page in line with the server's registrations for tenant users.

The landing, /get-started and /ai send anonymous click and section-view
counts (marketing_cta_clicked, marketing_section_viewed); every link and
button carries a data-track id. Layouts with their own OpenGraph metadata
keep the shared image and the large Twitter card again. The navbar tightens
between lg and xl so the new entry fits at 1024 px. The FAQ names the
documented MCP clients, and the terminal demo uses the real search_sessions
argument (deviceName).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 18:15:15 +02:00
Oliver Kieselbach e54b4585b8 bump pointers: internal, .claude/commands (event follow-up page live, D-304)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:17:41 +02:00
Oliver Kieselbach 7894d61e0b Event follow-up page for the Workplace Ninja Summit 2026
Unlisted page /events/<slug>/ for the link in the post-event mail: try it,
request a walkthrough, read the docs, plus the free community editions of
SCEPman and MyWorkID. It is noindex and kept out of the sitemap, robots.txt
and llms.txt. Every call to action sends one anonymous click event
(event_followup_clicked). The customer quote is now shared with the landing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 17:11:48 +02:00
Oliver Kieselbach 090f47d4fb Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-10-01 13:14:44 +02:00
Oliver Kieselbach c2d68b6537 bump pointer: internal (landing GitHub stars, D-303)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:13:35 +02:00
Oliver Kieselbach 5b0dfaad99 Landing shows the GitHub star count and a customer quote
The navbar gets a "Star" pill with the repository's star count from sm up;
phones keep the bare GitHub mark. A new section before the closing CTA
shows one customer quote with a star ask underneath. The footer's
"Star us on GitHub" line is gone, so the page asks twice, not three times.

The count is read once per web deploy (deploy-web.yml, own step, job token
only there) and inlined by next build as NEXT_PUBLIC_GITHUB_STARS, so
visitors' browsers never contact GitHub and the CSP stays as it is.
Without a value the pill renders without a counter (D-303).

The desktop links, bell and sign-in now start at lg instead of md: the bar
already overflowed between 768 and ~1000 px and the pill widened it further.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 13:13:33 +02:00
dependabot[bot] 50d29e61e8 chore(deps): bump the web-security group across 1 directory with 3 updates (#170)
Bumps the web-security group with 3 updates in the /src/Web/autopilot-monitor-web directory: [next](https://github.com/vercel/next.js), [brace-expansion](https://github.com/juliangruber/brace-expansion) and [dompurify](https://github.com/cure53/DOMPurify).


Updates `next` from 16.3.5 to 16.3.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.5...v16.3.6)

Updates `brace-expansion` from 1.1.18 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.18...v1.1.21)

Updates `dompurify` from 3.4.14 to 3.4.16
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.14...3.4.16)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.6
  dependency-type: direct:production
  dependency-group: web-security
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
  dependency-group: web-security
- dependency-name: dompurify
  dependency-version: 3.4.16
  dependency-type: indirect
  dependency-group: web-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 11:16:00 +02:00
dependabot[bot] 4b03ba4e19 chore(deps): bump fast-uri (#169)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [fast-uri](https://github.com/fastify/fast-uri).


Updates `fast-uri` from 3.1.7 to 3.1.8
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.8)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.8
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 11:15:42 +02:00
dependabot[bot] f0aec031f3 chore(deps): bump the actions group with 2 updates (#168)
Bumps the actions group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.0 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/analyze` from 4.38.0 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-01 09:28:08 +02:00
Oliver Kieselbach 66208b3767 bump pointer: internal (rule notification placeholders, D-302)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:25:11 +02:00
Oliver Kieselbach c60e07dfd4 Rule notifications resolve {{token}} placeholders before they are sent
A stored rule result keeps the rule's template; only the web and the MCP
server resolved it. The notification builders sent the explanation as
stored, so a channel card showed {{reason}}, {{appName}},
{{failedSubcategory}} and {{errorCode}} although the session had recorded
all four values.

- RuleTemplateInterpolator: the backend copy of the resolution, applied in
  BuildRuleFiredAlert and AddRuleResultSections before the text is cut.
  In a notification a list item whose value was not recorded is dropped
  and a token in running text becomes an en dash.
- The cut lands on a line, sentence or word boundary instead of a
  character count; the limits are unchanged.
- tests/fixtures/rule-template-interpolation/cases.json: one case file
  run by the backend, the web and the MCP server.
- The engine's evidence auto-field list is one constant. The web, the MCP
  interpolator and the validate_rule token lint gain timeoutHours, which
  the engine already recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:12:02 +02:00
Oliver Kieselbach f564ee6a06 bump pointer: internal (hello wait timeout released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:50:29 +02:00
Oliver Kieselbach ec025686ce bump pointer: internal (hello wait timeout plan, D-301)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:38:30 +02:00
Oliver Kieselbach 821c41e2ba Tenant Hello wait timeout up to 1 hour (#167)
* feat: tenant Hello wait timeout up to 1 hour

The "Hello Wait Timeout" setting (30..300 s in the UI) had no effect in the
V2 agent: its only consumer, HelloTracker.StartHelloWaitTimer, has no
production caller, while the decision engine armed a fixed 300 s
hello_safety deadline after the ESP exit and the tracker ran a fixed 300 s
completion timer after the wizard start.

The setting is now the real Hello budget, clamped to 300..3600 s
(HelloWaitTimeout in AutopilotMonitor.Shared). Every value that was valid
before (<= 300, including the old 30 s default) keeps today's 5 minutes.

- Engine: the agent stamps helloWaitTimeoutSeconds on EnrollmentFactsObserved
  (only when > 300), recorded set-once in EnrollmentScenarioObservations.
  All six hello_safety arm sites share one builder with the tenant window;
  a fact that arrives after hello_safety was armed moves it out. The WDP
  backstop is never shorter than the tenant window; the signal census
  surfaces hello_wait_extended.
- Agent: the Hello tracker's wizard completion timer uses the same budget;
  the stall probe skips session_stalled while hello_safety is pending.
- Backend: ValidateModel rejects values outside 300..3600; stored rows are
  read as their effective value, so legacy 30 s rows never block unrelated
  saves or backup reverts. Default 30 -> 300; shared manifest and wire types
  regenerated.
- Web: input 300..3600, bounds pinned against the C# constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

* fix: keep the Hello wait's 30 s minimum and default, raise only the maximum

Product decision: the setting keeps its UI contract (30 s minimum and
default, same description); only the maximum moves to 3600 s. Configured
values up to 300 s keep the built-in 5-minute Hello window, larger values
extend it.

- HelloWaitTimeout: MinSeconds/DefaultSeconds 30; BuiltInSeconds 300 is the
  floor of EffectiveSeconds.
- Backend validates only a changed value (30..3600), like the retention cap;
  the read-time normalization is gone again.
- The agent stamps the tenant window only above BuiltInSeconds.
- Web keeps the original text and default; only the maximum and its hint
  change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

* fix: Hello wait default 300 s, stale-fire guard, deferred stall report

Product decision: default 300 s (what V2 has always waited), minimum 30 s
still accepted, maximum 3600 s. Values up to 300 s keep the built-in
5-minute window, which the settings hint now says.

Review follow-ups:
- Engine: a hello_safety fire whose incarnation was replaced (armed due
  later than the fire's due time) is dead-ended, same guard as RealmJoin —
  a late tenant window could otherwise be undercut by the queued fire of
  the built-in deadline. The tenant fact is recorded only when it extends
  the built-in window.
- Stall probe: session_stalled is held back while a tenant-extended Hello
  wait (hello_safety or the WDP backstop) is pending and emitted once it is
  over and the session is still idle; real activity drops it. Default
  tenants never hit the guard.
- Comments: WDP backstop wording, 5-min reference, AgentConfiguration.
- Web pins the default against the shared manifest; tracker constructor
  defaults use HelloWaitTimeout.DefaultSeconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 15:36:36 +02:00
Oliver Kieselbach ec3eaa6437 fix(web): sitemap and llms.txt list public pages at their canonical /page/ URL (#166) 2026-09-29 14:13:59 +02:00
Oliver Kieselbach d36c9a659d Merge branch 'main' of https://github.com/okieselbach/AutopilotMonitor 2026-09-29 12:02:55 +02:00
Oliver Kieselbach c27dbf2aa2 bump pointer: internal (client role gates plan)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:46:31 +02:00
Oliver Kieselbach 28443d11fa fix(web): don't send SignalR joins and bootstrap reads the backend refuses by role
Every visit by a user without a tenant role joined the tenant broadcast
group before the dashboard redirected to /progress, and the settings
area loaded the bootstrap session list for every role. The backend
refused both by rule: 134 AddToGroup 403s, 19 serial-less session
joins and 27 bootstrap-list 403s in 30 days, each one also an
api_request_failed event.

- lib/signalrGroupAccess mirrors the backend's same-tenant join rules
  (broadcast and member-notify groups need an own role or platform
  scope, the admin-notify group the Admin or a GA, a session group a
  serial for roleless callers, global-admins platform scope, an
  unparseable name is a 400). Cross-tenant joins stay with the
  backend's platform/delegated admission. The gate keys on
  hasOwnTenantOrPlatformRole, not hasTenantReadScope: a delegated admin
  without an own role is refused the home tenant's groups too.
- SignalRContext.joinGroup applies it before sending: a refused join
  reaches onDenied with the server's status and is never held for the
  reconnect rejoin. joinGroup depends on the caller's role fields as
  primitives, so its identity changes only when auth/me resolves, the
  same commit that delivers the tenant id consumers join with. That
  also ends the "tenant-" join the dashboard sent before the tenant id
  was known (the 400s).
- TenantConfigContext loads bootstrap sessions only for
  canManageBootstrapSessions (mirror of BootstrapManagerOrGA: GA, Admin,
  Operator with the bootstrap grant).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:46:05 +02:00
Oliver Kieselbach 0c41b0f449 bump pointer: .claude/commands (ux-insights rejoin baseline)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:22:44 +02:00
Oliver Kieselbach 7799fdb725 bump pointer: .claude/commands (platform-health baselines 2026-09-29)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:04:44 +02:00
dependabot[bot] 0c1a2a4192 chore(deps-dev): bump tsx (#165)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 11:00:30 +02:00
Oliver Kieselbach 0356770ae8 bump pointer: internal (operator group customer label, D-300)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:58:39 +02:00
Oliver Kieselbach 3a9a074be1 Operator groups can carry a customer label; customers still never see the group name
Under D-267 a managed tenant saw every operator-provisioned access as
"Platform support". A customer in two operator groups run by different teams
got two identical entries under "Who can read this tenant" and could not tell
them apart.

A Global Admin can now give an operator group a customer label (at most 80
characters) with the existing PATCH global/tenant-groups/{groupId}, which takes
only the fields to change: name and/or customerLabel, where an empty label
clears it. The customer sees the label as the entry name; a group without one
and direct grants keep "Platform support". Owned (self-service) groups refuse a
label with 409, because their customers see the managing tenant's name.
RenameTenantGroupAsync becomes UpdateTenantGroupAsync, one read-modify-write on
the meta row.

DelegationSelfService.CustomerNameOf is now the one source of the name a
customer sees for a group. The managers view and the Group detail of the audit
rows written under every member tenant (assign, unassign, group delete) both use
it. Those audit rows carried the internal group name to the customer's audit
page before this change.

Admin, Tenant Groups: operator groups show "Customers see: ..." and get a
"Customer label" button next to Rename. The customer page is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:58:25 +02:00
Oliver Kieselbach a3870e7dcc bump pointer: internal (pre-provisioning seal + registration failure reason live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:59:09 +02:00
Oliver Kieselbach 8cb5d42edc Seal pre-provisioning despite IME's AccountSetup line; report why a session never registered
WhiteGlove seal: IME reports "EspPhase: AccountSetup" from the device session
as soon as the device apps are done when the user ESP is enabled. The sealing
classifier counted that as user activity (-40) and left pre-provisioning Part 1
unsealed, so no whiteglove.complete marker was written and the Part-2 boot hit
the 48-hour emergency break. The excluder now also needs the ESP registry to
show AccountSetup progress: the adapter stamps accountSetupProgress on
WhiteGloveShellCoreSuccess, only after the tracker has actually read the
AccountSetup subcategories (unread is never "untouched"). The new excluder is a
subset of the old one, so a verdict can only rise; signals without the key
score as before. WhiteGloveShellCoreSuccess v1 gets its replay fixture.

The Part-1 exit writes whiteglove.complete as its first step instead of after
analyzers, diagnostics upload, grace and drain, where a reseal could kill the
process before it.

Registration failures: a run that exits because register-session failed
records outcome, last error, network link at start and end, the wall-clock
attempt window and the config-fetch outcome in registration-failure.json. The
emergency break sends the record as AgentErrorReport.PriorRegistrationFailure
(null for older agents); the backend bounds it and adds it to the ops details,
the App Insights dimensions and the timeline data. Retry behaviour is unchanged.

The raw registry trigger whiteglove_signal_* is renamed save_result_step_*: the
step exists in every 25H2 enrollment and the old name read as a
pre-provisioning signal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:38:45 +02:00
Oliver Kieselbach fa637369e1 bump pointer: internal (emergency break session age live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:05:44 +02:00
Oliver Kieselbach edf30d35e0 Emergency break reports the session age; a break without a session row is Info
The agent measured the session age at its 48 h break but never sent it.
AgentErrorReport now carries SessionAgeHours (optional, null for older
agents), and the backend puts it into the ops details, the timeline data and
the App Insights dimension. It is information only: the backend cannot derive
it (no row, or a row registered seconds before the break after a hibernate
resume), and it never feeds the severity.

The backend now reads the Sessions row once for the owner binding and the
break verdict. A report for a session without a row (in practice one that
never registered) is Info and writes no timeline event and no EventType index
row: nothing can close or open that session, and the orphan sweep deleted the
event a day later with a Warning of its own. A row under cascade delete gets
nothing written past the lock. Terminal stays Info with lateCleanup; an open
status or a failed read stays Warning with the historical write. Break
materialization now requires GUID ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:40:36 +02:00
Oliver Kieselbach 3b4228e3a1 bump pointer: internal (session-owner binding stage 2 live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:38:45 +02:00
Oliver Kieselbach a029d69d5e Session-owner binding stage 2: refuse sessions bound to another device
Each Sessions row is bound to the device identity that registered it. The
binding ran in shadow; it now refuses every foreign-identity outcome:
register-session answers 403 with session_owner_mismatch before anything is
written, agent telemetry 403 in the error envelope (SessionOwnerMismatch),
the hosted diagnostics upload URL 403. The error channel stays 200 and only
skips its write into the named session. An evaluation defect fails open.

The agent already rotates its session id on the 403 at registration; it now
does the same for the coded 409 (session id claimed by another tenant) instead
of retrying six times and exiting, and fails without retry on any other coded
409 (BackendRefusalException, never an auth failure). A snapshot restored
after a rotation adopts the current session id.

A guard test pins the four call sites and fails when a site observes the
binding without acting on the refusal. Privacy page: session binding under
how data is protected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:18 +02:00
Oliver Kieselbach daae1ce1fa bump pointer: internal (paged cross-partition merge released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:02:35 +02:00
Oliver Kieselbach d90cf24508 bump pointer: internal (D-294/D-295 paged cross-partition merge)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach 32f1fd4132 Click-next lists change page only after the page loaded
The audit log, ops events, rule submissions and session reports each
carried a copy of the same paging state. Next bumped the page number and
cursor stack before the request and kept them when it failed, so a failed
request showed "Page 3" over the rows of page 1.

useCursorPager (pure transitions in lib/cursorPager) owns rows, cursors
and the stack; the page number is derived from the stack. A move commits
only after its page loaded, and only the latest move commits. The fetch is
read through useLatest, which retires the per-page workarounds for the
token getter's changing identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach ffb09e60fa Global audit and ops-event pages continue from one RowKey watermark
The cross-partition pages (audit log over all tenants, ops events over
all categories) kept one continuation entry per partition that still had
rows. On the tenant fan-out the nextLink outgrew the host's URL limit:
the follow-up request was rejected with HTTP 414 before the function ran,
so Next never loaded a second page for a Global Admin.

The continuation is now the (RowKey, Partition) of the last returned row.
Every page reads each partition through BoundedFanOut (pageSize + 1 rows
after the watermark) and merges by (RowKey, Partition), which is the
display order in both tables. Partitions after the watermark's partition
continue with `RowKey ge`, the others with `RowKey gt`, so equal ops
RowKeys across categories survive a page cut. The merge no longer sorts
by the resolved OccurredUtc while advancing by RowKey, which could skip
rows where the two disagree.

The fan-out was unbounded (Task.WhenAll over every tenant); it now runs
with the shared cross-tenant concurrency ceiling like the session list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 20:53:35 +02:00
Oliver Kieselbach f920b6228e fix(web): suspended and offboarded tenants get their page again (#164) 2026-09-28 18:23:28 +02:00
Oliver Kieselbach 1765773116 bump pointer: internal (RealmJoin app install rows released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:21:38 +02:00
Oliver Kieselbach 5c7295019b bump pointer: internal (D-292/D-293 RealmJoin app install rows)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:19:18 +02:00
Oliver Kieselbach a0ad769231 RealmJoin packages count as app installs next to Intune apps
App install rows now carry their install channel. The ingest folds the RealmJoin
watcher's package started/completed events into AppInstallSummaries rows (channel
"realmjoin", keyed by scope + package id; duration = completion minus the observed
start). IME rows keep their key and have no channel column, so they read as before.

Every per-app aggregate groups on (channel, name): apps list, app analytics and
sessions (new ?source=, default ime), metrics/app, SLA top failing apps and the
version-regression radar (tracker key, episode key, bell link, ops event). The app
install SLA rate includes RealmJoin packages. The ESP-blocking stamp stays IME-only.
RealmJoin packages do not feed the software inventory or CVE correlation.

Web: a RealmJoin pill and an All / Intune / RealmJoin filter on Software > Installs,
the pill on app detail, Fleet Health and SLA, source-aware app links and PDF meta
line, and a hint in the empty Inventory tab. The pill moves out of InstallProgress
into a shared component. MCP: get_app_install_metrics names the source field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 10:19:18 +02:00
Oliver Kieselbach 64da9f850b bump pointer: internal (D-291 chain build without downloads)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 09:07:10 +02:00
Oliver Kieselbach 5dae21968b Client-certificate chain building never downloads an issuer
The platform forwards whatever certificate the TLS client presented, so an
unauthenticated caller can hand in a leaf signed by a CA it made up. The
chain engine then fetched the leaf's AIA caIssuers URL before rejecting it:
an attacker-directed GET from the backend, and a worker blocked for the
10 s retrieval timeout. Every legitimate issuer is embedded and Intune
certificates carry no AIA, so downloads are now disabled and the unused
retrieval timeout is gone.

The regression test signs a leaf with a CA that is neither embedded nor
sent and asserts that the listener behind its AIA URL sees no connection;
a self-signed leaf never reaches that path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 09:06:09 +02:00
Oliver Kieselbach 59897dfbd8 bump pointer: internal (B-g9t editor verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:33:30 +02:00
Oliver Kieselbach f4551ea1ed bump pointer: internal (D-288/D-289/D-290 live)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:21:58 +02:00
Oliver Kieselbach e816c92b88 Tenant configuration has one conditional write path; the full-model PUT is gone
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.

- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
  read with ETag, tombstone refused, mutation decides, conditional replace, conditional
  insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
  TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
  conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
  winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
  auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
  Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
  quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
  the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
  suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
  Regenerated shared manifest and wire types (web, MCP).

Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).

Bumps internal (D-290, plan, docs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:11:56 +02:00
Oliver Kieselbach a16a70fba5 Self-service trial stays once per tenant across offboarding; plan writers decide on the fresh row
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated
it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding
and re-onboarding.

- The offboard click reads the configuration fail-loud and ORs TrialConsumed into the
  OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount).
- The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy)
  carries it onto the new row; an unreadable pointer means no write, retried next login.
  Re-onboarding itself is unchanged, auto-approve included.
- TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding
  tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with
  the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3).
  SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved
  (it could lift the tombstone during the drain barrier) and two concurrent trial starts
  cannot both pass.
- Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the
  offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in
  AuthFunctionSideEffectTests; shared EndpointHarness.

Bumps internal (D-288, D-289, plan, docs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 19:08:34 +02:00
Oliver Kieselbach 1b2bdd8f35 bump pointer: internal (SSRF gate released)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 13:43:22 +02:00
Oliver Kieselbach 942bd86238 bump pointer: internal (SSRF gate live-verify)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 13:08:24 +02:00
Oliver Kieselbach 7a920049ca bump pointer: internal (D-287 Node/Next version plan)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:48:30 +02:00
Oliver Kieselbach 2eeb4da950 bump pointer: internal (B-h4m CodeQL note)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:38:08 +02:00
Oliver Kieselbach 20a80cbdc6 bump pointer: internal (SSRF address gate D-286, B-h4m)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:37:03 +02:00
Oliver Kieselbach c59e65ee0a fix(backend): webhook SSRF gate moves onto the connection
SsrfGuard.ValidateDestinationAsync resolved the webhook host and checked
it, then HttpClient resolved it again for the POST, so a DNS-rebinding
host could pass the check and be connected to an internal address.

The webhook client's SocketsHttpHandler now connects through
SsrfGuard.ConnectToPublicAddress: it resolves once per connection,
refuses the connection if any address is blocked, enforces HTTPS and
connects the socket only to the checked addresses; TLS still runs against
the request host. The pre-flight check is removed, the registration moves
to AddWebhookNotificationHttpClient so a test can prove the wiring, the
notification retry policy no longer retries a refused destination, and
the test endpoint reports the refusal with the same message as before.

IsBlockedAddress follows the same model as the MCP server: all IANA
special-purpose space, IPv6 only 2000::/3, zone ids and IPv4-mapped
addresses refused (IPNetwork.Contains judges a mapped address by its
IPv4 form, so those are refused first).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:36:06 +02:00
Oliver Kieselbach 89aa9e1f10 fix(mcp): CIMD fetch checks the address inside the socket's own DNS lookup
The SSRF gate resolved the client_id host, checked the answer and then
handed the hostname to fetch(), which resolved it again at connect time.
A DNS-rebinding host could pass the check with a public answer and be
connected to an internal one.

The metadata fetch now runs through a dedicated undici dispatcher whose
connect.lookup is the gate: one resolution per connection, every address
must be public, the socket connects only to those addresses. The global
fetch is untouched.

The address classifier now refuses every IANA special-purpose block
(IPv4), allows only global unicast 2000::/3 minus its special-purpose
blocks (IPv6), and refuses zone ids and IPv4-mapped addresses. The tests
enumerate the unedited IANA registries (tests/fixtures/
iana-special-purpose-addresses) and prove on loopback sockets that a
refused host opens no socket and that an allowed one is connected to
exactly the address the gate returned.

Adds undici ^8.11.2 (engines >=22.19.0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 12:35:56 +02:00
Oliver Kieselbach 2d3700cfaf bump pointer: internal (B-5p4 release state)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 17:16:02 +02:00
381 changed files with 17755 additions and 3413 deletions
+2 -2
View File
@@ -114,7 +114,7 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -125,6 +125,6 @@ jobs:
run: dotnet build AutopilotMonitor.sln --configuration Release
- name: Analyze
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: /language:${{ matrix.language }}
+19
View File
@@ -78,6 +78,23 @@ jobs:
working-directory: ${{ env.WEB_DIR }}
run: npm ci
# Star counter for the GitHub star link (utils/githubStars.ts). Baked into the static HTML
# so visitors' browsers never call GitHub; stale until the next web deploy. The token stays
# in this step, never in the build. Fail-soft: without a number the link has no counter.
- name: Read GitHub star count (fail-soft)
id: stars
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
count=$(gh api "repos/$REPO" --jq .stargazers_count 2>/dev/null || true)
if [[ "$count" =~ ^[0-9]+$ ]]; then
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "GitHub stars: $count"
else
echo "::warning::GitHub star count unavailable - the star link renders without a counter"
fi
# next.config output:'export' -- `next build` writes the static site to out/
# (prebuild: sync-error-codes, generate-lastmod, generate-version, whats-new).
- name: Build
@@ -98,6 +115,8 @@ jobs:
GITHUB_SHA: ${{ github.sha }}
# Docs checkout for public/whats-new.json; the prebuild fails in CI without it.
WHATS_NEW_DOCS_DIR: ${{ github.workspace }}/whats-new-docs
# Star counter from the step above; empty when the lookup failed.
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
run: |
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
+2 -2
View File
@@ -203,7 +203,7 @@
"category": "currentPhase",
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"enabled": true
},
{
@@ -584,7 +584,7 @@
"category": "always",
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
"enabled": true
},
{
+1 -1
View File
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "^\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent in 1.104.102.0 and 1.105.103.0 decompiles, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration. IME removed the [DO TEL] emitter in 1.104 (absent from the 1.104.102.0 and 1.105.103.0 builds, present in 1.97.107.0); kept while 1.101/1.103 devices are in the fleet — retire once those versions are gone.",
"enabled": true
}
@@ -4,6 +4,6 @@
"category": "always",
"pattern": "^Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104.",
"enabled": true
}
@@ -75,15 +75,17 @@
.NOTES
Requires:
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
- Sign-in as Global Administrator OR Privileged Role Administrator (with PIM, activate the
role first). Only these roles may assign Microsoft Graph application permissions;
Application Administrator and Cloud Application Administrator are not enough.
- The Autopilot Monitor app must be admin-consented in your tenant first
(its service principal must exist before grants can be added).
Sign-in behaviour:
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
token for the already-signed-in user) - no device-code prompt, which Conditional Access
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
- The Autopilot Monitor app must be admin-consented in your tenant first
(its service principal must exist before grants can be added).
After granting:
The Autopilot Monitor backend may cache an older token for up to ~1 hour. Use the
@@ -139,6 +141,36 @@ if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
# ---- Constants ----
$MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000'
# ---- Graph failure details ----
# Invoke-MgGraphRequest puts only "Response status code does not indicate success: ..." into the
# exception message. Graph's own error (code + message) is the response body in ErrorDetails,
# behind the request line, the status line and the headers. Some headers carry JSON themselves
# (x-ms-ags-diagnostic), so the body is taken from behind the blank line that ends the headers.
function Get-GraphFailure {
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
$status = 0
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
$status = [int]$ErrorRecord.Exception.Response.StatusCode
}
$text = $ErrorRecord.Exception.Message
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
$body = ($ErrorRecord.ErrorDetails.Message -split '\r?\n\r?\n', 2)[-1].Trim()
if ($body.StartsWith('{')) {
try {
$graphError = ($body | ConvertFrom-Json).error
if ($graphError.code) { $text = "$($graphError.code): $($graphError.message)" }
}
catch {
# Not a Graph JSON body - keep the exception message.
}
}
}
[pscustomobject]@{ Status = $status; Text = $text }
}
# ---- Pre-flight: module ----
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
Write-Host "[Setup] Microsoft.Graph.Authentication not installed. Installing for current user..." -ForegroundColor Yellow
@@ -188,7 +220,7 @@ Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
# and its effective rights follow the signed-in user's directory role.
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Administrator or Privileged Role Administrator)."
if (-not $VerifyOnly) { return }
}
@@ -262,6 +294,7 @@ $action = if ($Revoke) { 'Revoke' } else { 'Grant' }
Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan
$results = @()
$graphForbidden = $false
foreach ($permName in $Permissions) {
$appRole = $graphSp.appRoles | Where-Object {
$_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application'
@@ -288,7 +321,9 @@ foreach ($permName in $Permissions) {
$results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' }
}
catch {
Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)"
$failure = Get-GraphFailure $_
if ($failure.Status -eq 403) { $graphForbidden = $true }
Write-Warning " [FAIL] '$permName' revoke failed: $($failure.Text)"
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
}
}
@@ -315,7 +350,9 @@ foreach ($permName in $Permissions) {
$results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' }
}
catch {
Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)"
$failure = Get-GraphFailure $_
if ($failure.Status -eq 403) { $graphForbidden = $true }
Write-Warning " [FAIL] '$permName' grant failed: $($failure.Text)"
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
}
}
@@ -324,6 +361,9 @@ foreach ($permName in $Permissions) {
Write-Host ""
Write-Host "=========== Summary ===========" -ForegroundColor Cyan
$results | Format-Table -AutoSize | Out-Host
if ($graphForbidden) {
Write-Warning "Microsoft Graph refused the change (403): the signed-in account may not manage Microsoft Graph application permissions. Sign in as Global Administrator or Privileged Role Administrator (with PIM, activate the role first) and run the script again."
}
Write-Host ""
Write-Host "Note: token-cached backends may need up to ~1h to see the new permission set." -ForegroundColor Yellow
Write-Host " Use 'Refresh permission status' in the admin UI to apply changes immediately." -ForegroundColor Yellow
@@ -41,15 +41,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
}
private GatherRuleContext Context(bool unrestricted = false, string? imeLogPathOverride = null)
=> new GatherRuleContext(
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
return new GatherRuleContext(
logger,
"sess", "tenant",
evt => _events.Add(evt),
imeLogPathOverride,
new LogFilePositionTracker())
new GatherRuleSessionState("sess", null, logger))
{
UnrestrictedMode = unrestricted
};
}
private static GatherRule Rule(string collectorType, string target,
Dictionary<string, string>? parameters = null) => new GatherRule
@@ -94,7 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
public void Context_DebugLog_is_noop_without_writer()
{
var context = new GatherRuleContext(
_logger, "sess", "tenant", _ => { }, null, new LogFilePositionTracker());
_logger, "sess", "tenant", _ => { }, null, new GatherRuleSessionState("sess", null, _logger));
context.DebugLog("RULE-1", GatherRuleDebugLog.StageScope, "should not throw, should not write");
@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// An agent restart inside one enrollment session — a new <see cref="GatherRuleExecutor"/> on the
/// same state directory — continues where the previous run stopped: logparser rules do not
/// replay their log, on_change rules do not re-emit an unchanged result. Regression anchor:
/// session c8106021, where every restart replayed the whole HP Image Assistant log (70 events).
/// </summary>
[Collection("SerialThreading")] // startup rules execute on the shared ThreadPool
public sealed class GatherRuleExecutorRestartTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly TempDirectory _stateDir = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly object _eventsGate = new object();
private readonly List<GatherRuleExecutor> _executors = new List<GatherRuleExecutor>();
// %TEMP% lives under C:\Users (hard-blocked) — log targets sit beside the test assembly.
private readonly string _logDir = Path.Combine(
AppContext.BaseDirectory, "gather-restart-tests-" + Guid.NewGuid().ToString("N"));
public GatherRuleExecutorRestartTests()
{
Directory.CreateDirectory(_logDir);
}
public void Dispose()
{
foreach (var executor in _executors)
executor.Dispose();
_tmp.Dispose();
_stateDir.Dispose();
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
}
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
private List<EnrollmentEvent> Events
{
get { lock (_eventsGate) return _events.ToList(); }
}
/// <summary>One agent run: a fresh executor whose startup rules run once. Not disposed — a hard kill.</summary>
private GatherRuleExecutor AgentRun(GatherRule rule, string sessionId = "session-a", string? stateDirectory = "default",
Action<EnrollmentEvent>? onEvent = null, string? debugLogPath = null)
{
var executor = new GatherRuleExecutor(
sessionId, "tenant",
evt =>
{
onEvent?.Invoke(evt);
lock (_eventsGate) _events.Add(evt);
},
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
debugLogPath: debugLogPath,
stateDirectory: stateDirectory == "default" ? _stateDir.Path : stateDirectory)
{
UnrestrictedMode = true
};
_executors.Add(executor);
executor.UpdateRules(new List<GatherRule> { rule });
Assert.True(executor.WaitForStartupRules(30));
return executor;
}
private static GatherRule LogRule(string path, string id = "hpia-log-collect") => new GatherRule
{
RuleId = id,
Title = id,
CollectorType = "logparser",
Target = path,
Parameters = new Dictionary<string, string> { ["pattern"] = "^(?<status>Update .+)$", ["format"] = "text" },
Trigger = "startup",
OutputEventType = "HPiA-UpdateStatus",
Enabled = true,
};
private static GatherRule OnChangeFileRule(string path) => new GatherRule
{
RuleId = "GATHER-OC-FILE",
Title = "file presence",
CollectorType = "file",
Target = path,
Trigger = "startup",
OutputEventType = "gather_file_presence",
Enabled = true,
EmitMode = "on_change",
};
private string WriteLog(string name, string content)
{
var path = Path.Combine(_logDir, name);
File.WriteAllText(path, content);
return path;
}
private int StatusEvents => Events.Count(e => e.EventType == "HPiA-UpdateStatus");
[Fact]
public void Restart_does_not_replay_the_log_and_new_lines_arrive_once()
{
var path = WriteLog("HP Image Assistant.log", "Update one\nUpdate two\nnoise\n");
AgentRun(LogRule(path));
Assert.Equal(2, StatusEvents);
AgentRun(LogRule(path)); // reboot, nothing new
Assert.Equal(2, StatusEvents);
File.AppendAllText(path, "Update three\n");
AgentRun(LogRule(path)); // next restart picks up only the new line
Assert.Equal(3, StatusEvents);
Assert.Equal("Update three", Events.Last(e => e.EventType == "HPiA-UpdateStatus").Data["status"]);
}
[Fact]
public void Position_is_on_disk_right_after_the_run_without_a_clean_shutdown()
{
var path = WriteLog("hardkill.log", "Update one\n");
AgentRun(LogRule(path)); // never disposed
var saved = JsonConvert.DeserializeObject<GatherRuleStateData>(File.ReadAllText(StateFile))!;
var position = Assert.Single(saved.LogPositions!);
Assert.Equal(new FileInfo(path).Length, position.Position);
Assert.Equal("session-a", saved.SessionId);
}
[Fact]
public void Position_is_saved_only_after_the_events_went_out()
{
var path = WriteLog("ordering.log", "Update one\n");
var savedDuringEmit = new List<bool>();
AgentRun(LogRule(path), onEvent: _ => savedDuringEmit.Add(File.Exists(StateFile)));
Assert.Equal(new[] { false }, savedDuringEmit);
Assert.True(File.Exists(StateFile));
}
[Fact]
public void A_new_session_reads_the_log_from_the_beginning()
{
var path = WriteLog("rotated.log", "Update one\nUpdate two\n");
AgentRun(LogRule(path), sessionId: "session-a");
AgentRun(LogRule(path), sessionId: "session-b"); // session rotated on the same device
Assert.Equal(4, StatusEvents);
}
[Fact]
public void A_log_replaced_during_the_reboot_is_read_from_the_beginning()
{
var path = WriteLog("replaced.log", "Update one\n");
AgentRun(LogRule(path, "replaced"));
// The tool writes a new log after the reboot; it is already longer than the old position.
File.WriteAllText(path, "Update fresh A\nUpdate fresh B\nUpdate fresh C\n");
AgentRun(LogRule(path, "replaced"));
Assert.Equal(new[] { "Update one", "Update fresh A", "Update fresh B", "Update fresh C" },
Events.Where(e => e.EventType == "HPiA-UpdateStatus").Select(e => (string)e.Data["status"]).ToArray());
}
[Fact]
public void An_unchanged_on_change_result_stays_silent_after_a_restart()
{
var watched = Path.Combine(_logDir, "marker.txt");
AgentRun(OnChangeFileRule(watched)); // absent → first result emits
AgentRun(OnChangeFileRule(watched)); // reboot, still absent → silent
Assert.Single(Events, e => e.EventType == "gather_file_presence");
File.WriteAllText(watched, "present");
AgentRun(OnChangeFileRule(watched)); // changed → emits
Assert.Equal(2, Events.Count(e => e.EventType == "gather_file_presence"));
}
[Fact]
public void Without_a_state_directory_every_run_starts_over_and_writes_nothing()
{
var path = WriteLog("diagnostic.log", "Update one\n");
AgentRun(LogRule(path), stateDirectory: null);
AgentRun(LogRule(path), stateDirectory: null);
Assert.Equal(2, StatusEvents);
Assert.False(File.Exists(StateFile));
}
[Fact]
public void Debug_trace_says_what_was_restored_and_where_reading_continues()
{
var path = WriteLog("traced.log", "Update one\n");
var trace = Path.Combine(_tmp.Path, "gather-rules-debug.log");
AgentRun(LogRule(path));
File.AppendAllText(path, "Update two\n");
AgentRun(LogRule(path), debugLogPath: trace);
var text = File.ReadAllText(trace);
Assert.Contains("gather state: restored 1 logparser position(s) for 1 rule(s)", text);
Assert.Contains("hpia-log-collect | config | logparser position restored: traced.log", text);
Assert.Contains("traced.log: continuing at position", text);
}
}
}
@@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// <see cref="GatherRuleSessionState"/> and <see cref="GatherRuleStatePersistence"/>: what is
/// written, what a restart restores, what it refuses, and when the file is (not) written.
/// </summary>
public sealed class GatherRuleSessionStateTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly TempDirectory _stateDir = new TempDirectory();
private readonly AgentLogger _logger;
public GatherRuleSessionStateTests()
{
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
}
public void Dispose()
{
_tmp.Dispose();
_stateDir.Dispose();
}
private string StateFile => Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
private GatherRuleSessionState NewState(string sessionId = "session-a") =>
new GatherRuleSessionState(sessionId, new GatherRuleStatePersistence(_stateDir.Path, _logger), _logger);
private static LogFileHead Head(string text) => LogFileHead.FromBytes(Encoding.UTF8.GetBytes(text));
// ── restore ─────────────────────────────────────────────────────────
[Fact]
public void Positions_and_emitted_hashes_survive_a_restart_of_the_same_session()
{
var first = NewState();
first.CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("2026-09-28 a.log start"), heldTailStart: -1);
first.CommitEmittedHash("RULE-2", "hash-value");
var second = NewState();
var position = second.PeekLogPosition("RULE-1", @"C:\Logs\a.log");
Assert.NotNull(position);
Assert.Equal(120, position!.Position);
Assert.Equal(7, position.LineNumber);
Assert.True(position.FromPreviousRun);
Assert.Equal("hash-value", second.GetEmittedHashes()["RULE-2"]);
Assert.StartsWith("restored 1 logparser position(s) for 1 rule(s) and 1 on_change value(s)", second.RestoreSummary);
}
[Fact]
public void State_of_another_session_is_ignored()
{
NewState("session-a").CommitLogPosition("RULE-1", @"C:\Logs\a.log", 120, 7, Head("start"), -1);
var rotated = NewState("session-b");
Assert.Null(rotated.PeekLogPosition("RULE-1", @"C:\Logs\a.log"));
Assert.Contains("saved for session session-a, current session is session-b", rotated.RestoreSummary);
}
[Fact]
public void Missing_file_is_a_first_run_and_corrupt_file_a_fresh_start()
{
Assert.StartsWith("no saved state", NewState().RestoreSummary);
File.WriteAllText(StateFile, "not json {{{");
var afterCorruption = NewState();
Assert.StartsWith("saved state discarded", afterCorruption.RestoreSummary);
Assert.Empty(afterCorruption.GetEmittedHashes());
}
[Fact]
public void Tampered_entries_are_dropped_and_the_rest_restored()
{
var data = new GatherRuleStateData
{
SessionId = "session-a",
SavedAtUtc = DateTime.UtcNow,
LogPositions = new List<GatherLogPositionData>
{
new GatherLogPositionData { RuleId = "OK", Path = @"C:\a.log", Position = 10, LineNumber = 1, HeadLength = 10, HeadHash = "h" },
new GatherLogPositionData { RuleId = "NEG", Path = @"C:\b.log", Position = -5, HeadLength = 10, HeadHash = "h" },
new GatherLogPositionData { RuleId = "", Path = @"C:\c.log", Position = 1, HeadLength = 1, HeadHash = "h" },
new GatherLogPositionData { RuleId = "BIGHEAD", Path = @"C:\d.log", Position = 1, HeadLength = 5000, HeadHash = "h" },
new GatherLogPositionData { RuleId = "NOHEAD", Path = @"C:\e.log", Position = 50, HeadLength = 0 },
},
OnChangeHashes = new Dictionary<string, string> { ["GOOD"] = "x", ["EMPTY"] = "" },
};
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
var state = NewState();
Assert.NotNull(state.PeekLogPosition("OK", @"C:\a.log"));
Assert.Null(state.PeekLogPosition("NEG", @"C:\b.log"));
Assert.Null(state.PeekLogPosition("BIGHEAD", @"C:\d.log"));
Assert.Null(state.PeekLogPosition("NOHEAD", @"C:\e.log"));
Assert.Equal(new[] { "GOOD" }, state.GetEmittedHashes().Keys.ToArray());
Assert.Contains("5 invalid entries dropped", state.RestoreSummary);
}
[Fact]
public void A_path_no_real_file_can_have_is_dropped_and_never_stops_the_executor()
{
// Path APIs throw on these characters under .NET Framework; a throw here would keep
// every collector host down at every restart of the session.
var data = new GatherRuleStateData
{
SessionId = "session-a",
LogPositions = new List<GatherLogPositionData>
{
new GatherLogPositionData { RuleId = "BAD", Path = "C:\\Logs\\x.|og", Position = 1, HeadLength = 1, HeadHash = "h" },
new GatherLogPositionData { RuleId = "WILD", Path = "C:\\Logs\\*.log", Position = 1, HeadLength = 1, HeadHash = "h" },
new GatherLogPositionData { RuleId = "OK", Path = "C:\\Logs\\ok.log", Position = 1, HeadLength = 1, HeadHash = "h" },
},
};
File.WriteAllText(StateFile, JsonConvert.SerializeObject(data));
var state = NewState();
Assert.NotNull(state.PeekLogPosition("OK", "C:\\Logs\\ok.log"));
Assert.Null(state.PeekLogPosition("BAD", "C:\\Logs\\x.|og"));
Assert.Contains("2 invalid entries dropped", state.RestoreSummary);
}
// ── where reading starts ────────────────────────────────────────────
[Fact]
public void Reading_continues_while_the_file_head_still_matches()
{
var state = NewState();
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
var start = GatherRuleSessionState.ResolveStart(state.PeekLogPosition("R", @"C:\a.log"), 200,
Head("2026-09-28 first line\nsecond line\nthird"), out var how);
Assert.Equal(30, start);
Assert.Equal(LogReadStart.Continue, how);
}
[Fact]
public void A_shrunk_file_and_a_replaced_file_are_read_from_the_beginning()
{
var state = NewState();
state.CommitLogPosition("R", @"C:\a.log", 30, 3, Head("2026-09-28 first line\n"), -1);
var known = state.PeekLogPosition("R", @"C:\a.log");
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 20, Head("2026-09-28 fir"), out var shrunk));
Assert.Equal(LogReadStart.Shrunk, shrunk);
// Replaced and already longer than the old position: only the head tells.
Assert.Equal(0, GatherRuleSessionState.ResolveStart(known, 500, Head("2026-09-30 a new log entirely, much longer"), out var replaced));
Assert.Equal(LogReadStart.Replaced, replaced);
// Shorter than the recorded head although not shorter than the position.
state.CommitLogPosition("S", @"C:\b.log", 5, 1, Head("0123456789abcdef"), -1);
Assert.Equal(0, GatherRuleSessionState.ResolveStart(state.PeekLogPosition("S", @"C:\b.log"), 8, Head("01234567"), out var cut));
Assert.Equal(LogReadStart.Replaced, cut);
}
[Fact]
public void Positions_are_kept_per_rule_for_the_same_file()
{
var state = NewState();
state.CommitLogPosition("RULE-A", @"C:\shared.log", 100, 10, Head("x"), -1);
Assert.Null(state.PeekLogPosition("RULE-B", @"C:\shared.log"));
Assert.NotNull(state.PeekLogPosition("RULE-A", @"C:\SHARED.LOG")); // paths compare case-insensitively
}
// ── when the file is written ────────────────────────────────────────
[Fact]
public void An_unchanged_commit_does_not_write_the_file()
{
var state = NewState();
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
File.Delete(StateFile);
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
state.CommitEmittedHash("H", "v");
File.Delete(StateFile);
state.CommitEmittedHash("H", "v");
Assert.False(File.Exists(StateFile));
}
[Fact]
public void A_failed_save_is_retried_at_the_next_commit()
{
var blocked = Path.Combine(_stateDir.Path, "blocked");
File.WriteAllText(blocked, "a file where the state directory should be");
var state = new GatherRuleSessionState("session-a", new GatherRuleStatePersistence(blocked, _logger), _logger);
state.CommitEmittedHash("H", "v"); // Directory.CreateDirectory fails
File.Delete(blocked);
state.CommitEmittedHash("H", "v"); // unchanged, but the earlier save is still owed
Assert.True(File.Exists(Path.Combine(blocked, GatherRuleStatePersistence.FileName)));
}
[Fact]
public void A_closed_state_no_longer_writes()
{
var state = NewState();
state.Close();
state.CommitEmittedHash("H", "v");
Assert.False(File.Exists(StateFile));
}
[Fact]
public void Without_persistence_nothing_is_written()
{
var state = new GatherRuleSessionState("session-a", null, _logger);
state.CommitLogPosition("R", @"C:\a.log", 10, 1, Head("head"), -1);
Assert.StartsWith("persistence off", state.RestoreSummary);
Assert.Empty(Directory.GetFiles(_stateDir.Path));
}
// ── bounds and concurrency ──────────────────────────────────────────
[Fact]
public void A_rule_forgets_its_least_recently_touched_file_beyond_the_cap()
{
var state = NewState();
for (var i = 0; i < GatherRuleSessionState.MaxLogFilesPerRule; i++)
state.CommitLogPosition("R", $@"C:\logs\{i}.log", 1, 1, Head("h"), -1);
state.PeekLogPosition("R", @"C:\logs\0.log"); // touched again — survives
state.CommitLogPosition("R", @"C:\logs\new.log", 1, 1, Head("h"), -1);
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\0.log"));
Assert.Null(state.PeekLogPosition("R", @"C:\logs\1.log"));
Assert.NotNull(state.PeekLogPosition("R", @"C:\logs\new.log"));
}
[Fact]
public void Parallel_commits_leave_a_consistent_file()
{
var state = NewState();
Parallel.For(0, 8, worker =>
{
for (var i = 0; i < 50; i++)
{
state.CommitLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log", i + 1, i, Head("h"), -1);
state.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log");
state.CommitEmittedHash($"RULE-{worker}", "hash-" + i);
}
});
var restored = NewState();
for (var worker = 0; worker < 8; worker++)
{
Assert.Equal(50, restored.PeekLogPosition($"RULE-{worker}", $@"C:\logs\{worker}.log")!.Position);
Assert.Equal("hash-49", restored.GetEmittedHashes()[$"RULE-{worker}"]);
}
}
}
}
@@ -0,0 +1,188 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// <see cref="LogLineReader"/>, <see cref="LogTextEncoding"/> and <see cref="LogFileHead"/>:
/// exact byte positions, StreamReader-compatible line ends, byte order marks only at offset 0,
/// and the code-unit view of UTF-16/UTF-32 files.
/// </summary>
public sealed class LogLineReaderTests
{
private static byte[] Bytes(Encoding encoding, string text, bool withBom = true)
{
var body = encoding.GetBytes(text);
return withBom ? encoding.GetPreamble().Concat(body).ToArray() : body;
}
private static (List<string> Lines, List<long> EndPositions, List<bool> Terminated) ReadAll(
byte[] file, long start = 0, int maxLineBytes = 1024)
{
var encoding = LogFileHead.FromBytes(file.Take(LogFileHead.MaxBytes).ToArray()).Encoding;
using (var stream = new MemoryStream(file))
{
stream.Seek(start, SeekOrigin.Begin);
var reader = new LogLineReader(stream, encoding, maxLineBytes);
var lines = new List<string>();
var ends = new List<long>();
var terminated = new List<bool>();
string line;
while ((line = reader.ReadLine()) != null)
{
lines.Add(line);
ends.Add(reader.Position);
terminated.Add(reader.LastLineTerminated);
}
return (lines, ends, terminated);
}
}
[Fact]
public void Line_ends_match_StreamReader_including_a_lone_carriage_return()
{
var file = Encoding.UTF8.GetBytes("one\ntwo\r\nthree\rfour");
var result = ReadAll(file);
Assert.Equal(new[] { "one", "two", "three", "four" }, result.Lines);
Assert.Equal(new long[] { 4, 9, 15, 19 }, result.EndPositions);
Assert.Equal(new[] { true, true, true, false }, result.Terminated);
}
[Fact]
public void Position_after_each_line_is_the_offset_to_resume_from()
{
var file = Encoding.UTF8.GetBytes("alpha\nbeta\ngamma\n");
var first = ReadAll(file);
var resumed = ReadAll(file, start: first.EndPositions[0]);
Assert.Equal(new[] { "beta", "gamma" }, resumed.Lines);
Assert.Equal(file.Length, resumed.EndPositions.Last());
}
[Fact]
public void Utf8_byte_order_mark_is_skipped_only_at_offset_0()
{
var file = Bytes(Encoding.UTF8, "first\nsecond\n");
var result = ReadAll(file);
Assert.Equal(new[] { "first", "second" }, result.Lines);
Assert.Equal(LogTextEncoding.Utf8WithBom, LogFileHead.FromBytes(file).Encoding);
}
[Theory]
[InlineData("utf-16le")]
[InlineData("utf-16be")]
[InlineData("utf-32le")]
[InlineData("utf-32be")]
public void Unicode_files_are_read_in_their_code_units_from_any_resume_point(string name)
{
var encoding = name == "utf-16le" ? Encoding.Unicode
: name == "utf-16be" ? Encoding.BigEndianUnicode
: name == "utf-32le" ? Encoding.UTF32
: new UTF32Encoding(bigEndian: true, byteOrderMark: true);
var file = Bytes(encoding, "ERROR first\r\nERROR zweite Zeile mit Umlaut äöü\r\n");
var whole = ReadAll(file);
var resumed = ReadAll(file, start: whole.EndPositions[0]);
Assert.Equal(name, LogFileHead.FromBytes(file).Encoding.Name);
Assert.Equal(new[] { "ERROR first", "ERROR zweite Zeile mit Umlaut äöü" }, whole.Lines);
Assert.Equal(new[] { "ERROR zweite Zeile mit Umlaut äöü" }, resumed.Lines);
Assert.Equal(file.Length, whole.EndPositions.Last());
}
[Fact]
public void File_without_byte_order_mark_is_utf8_and_ansi_bytes_decode_as_before()
{
var file = new byte[] { (byte)'M', (byte)'a', (byte)'n', 0xFC, (byte)'\n' }; // "Man" + Windows-1252 'ü'
var result = ReadAll(file);
Assert.Equal(LogTextEncoding.Utf8, LogFileHead.FromBytes(file).Encoding);
Assert.Equal(new StreamReader(new MemoryStream(file), Encoding.UTF8).ReadLine(), result.Lines.Single());
}
[Fact]
public void Carriage_return_at_end_of_file_ends_the_line_and_its_late_line_feed_is_skipped()
{
var firstWrite = Encoding.UTF8.GetBytes("done\r");
var later = Encoding.UTF8.GetBytes("done\r\nnext\n");
var first = ReadAll(firstWrite);
var resumed = ReadAll(later, start: first.EndPositions.Last());
Assert.Equal(new[] { "done" }, first.Lines);
Assert.True(first.Terminated.Single());
Assert.Equal(new[] { "next" }, resumed.Lines);
}
[Fact]
public void Overlong_line_is_consumed_without_being_kept_and_reading_continues_behind_it()
{
var file = Encoding.UTF8.GetBytes(new string('x', 50) + "\nshort\n");
using (var stream = new MemoryStream(file))
{
var reader = new LogLineReader(stream, LogTextEncoding.Utf8, maxLineBytes: 10);
Assert.Equal(string.Empty, reader.ReadLine());
Assert.True(reader.LastLineTruncated);
Assert.Equal(51, reader.Position);
Assert.Equal("short", reader.ReadLine());
Assert.False(reader.LastLineTruncated);
Assert.Null(reader.ReadLine());
}
}
[Fact]
public void Line_spanning_many_read_buffers_is_returned_whole()
{
var longLine = new string('y', 200_000);
var file = Encoding.UTF8.GetBytes(longLine + "\nend\n");
var result = ReadAll(file, maxLineBytes: 1_000_000);
Assert.Equal(new[] { longLine, "end" }, result.Lines);
}
[Fact]
public void Half_written_utf16_character_at_end_of_file_stays_in_the_unterminated_line()
{
var full = Bytes(Encoding.Unicode, "ok\r\nhalf");
var file = full.Concat(new byte[] { 0x41 }).ToArray(); // first byte of the next UTF-16 unit
var result = ReadAll(file);
Assert.Equal("ok", result.Lines[0]);
Assert.False(result.Terminated.Last());
Assert.Equal(file.Length, result.EndPositions.Last());
}
[Fact]
public void AlignDown_snaps_to_code_units_behind_the_byte_order_mark()
{
Assert.Equal(0, LogTextEncoding.Utf16LittleEndian.AlignDown(1));
Assert.Equal(2, LogTextEncoding.Utf16LittleEndian.AlignDown(3));
Assert.Equal(6, LogTextEncoding.Utf16LittleEndian.AlignDown(7));
Assert.Equal(8, LogTextEncoding.Utf32LittleEndian.AlignDown(11));
Assert.Equal(7, LogTextEncoding.Utf8.AlignDown(7));
}
[Fact]
public void Head_hash_of_a_prefix_matches_the_hash_of_the_shorter_file()
{
var shortFile = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start"));
var grown = LogFileHead.FromBytes(Encoding.UTF8.GetBytes("2026-09-28 start\nmore lines"));
Assert.Equal(shortFile.Hash(shortFile.Length), grown.Hash(shortFile.Length));
Assert.NotEqual(shortFile.Hash(shortFile.Length), grown.Hash(grown.Length));
}
}
}
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
logger, "sess", "tenant",
evt => _events.Add(evt),
null,
new LogFilePositionTracker(),
new GatherRuleSessionState("sess", null, logger),
new GatherRuleDebugLog(_debugLogPath, logger))
{
UnrestrictedMode = true
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
{
/// <summary>
/// Reading semantics of <see cref="LogParserCollector"/> across runs on one context: every
/// line of a growing log is matched exactly once — no line lost at a maxLines cut, no line
/// split by a writer that is mid-line, UTF-16 logs readable after the first run, positions
/// kept per rule, line numbers counted from the start of the file.
/// </summary>
public sealed class LogParserCollectorReadingTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
private readonly GatherRuleContext _context;
// %TEMP% lives under C:\Users (hard-blocked) — parse targets sit beside the
// test assembly instead, admitted via UnrestrictedMode.
private readonly string _logDir = Path.Combine(
AppContext.BaseDirectory, "gather-reading-tests-" + Guid.NewGuid().ToString("N"));
public LogParserCollectorReadingTests()
{
Directory.CreateDirectory(_logDir);
_context = NewContext();
}
public void Dispose()
{
_tmp.Dispose();
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
}
private GatherRuleContext NewContext()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
return new GatherRuleContext(logger, "sess", "tenant",
evt => _events.Add(evt), null, new GatherRuleSessionState("sess", null, logger))
{
UnrestrictedMode = true
};
}
private string LogPath(string name) => Path.Combine(_logDir, name);
private static GatherRule TextRule(string ruleId, string target, string pattern,
string trigger = "interval", int? maxLines = null)
{
var parameters = new Dictionary<string, string> { ["pattern"] = pattern, ["format"] = "text" };
if (maxLines.HasValue) parameters["maxLines"] = maxLines.Value.ToString();
return new GatherRule
{
RuleId = ruleId,
Title = ruleId,
CollectorType = "logparser",
Target = target,
Parameters = parameters,
Trigger = trigger,
IntervalSeconds = trigger == "interval" ? 30 : (int?)null,
OutputEventType = "logparser_reading_test",
};
}
private List<string> Captured(string group) =>
_events.Select(e => e.Data.TryGetValue(group, out var v) ? (string)v : "<missing>").ToList();
[Fact]
public void Two_rules_on_one_file_each_read_the_whole_file()
{
var path = LogPath("shared.log");
File.WriteAllText(path, "ERROR one\nWARN two\nERROR three\n");
var collector = new LogParserCollector();
collector.Execute(TextRule("RULE-ERRORS", path, "^ERROR (?<what>\\w+)"), _context);
collector.Execute(TextRule("RULE-WARNINGS", path, "^WARN (?<what>\\w+)"), _context);
Assert.Equal(2, _events.Count(e => (string)e.Data["ruleId"] == "RULE-ERRORS"));
Assert.Single(_events, e => (string)e.Data["ruleId"] == "RULE-WARNINGS");
}
[Fact]
public void MaxLines_cut_loses_no_line_across_runs()
{
var path = LogPath("long.log");
var sb = new StringBuilder();
for (var i = 1; i <= 30; i++)
sb.Append("hit ").Append(i.ToString("D3")).Append(' ').Append('x', 90).Append('\n');
File.WriteAllText(path, sb.ToString());
var rule = TextRule("RULE-LONG", path, "^hit (?<n>\\d{3})", maxLines: 10);
var collector = new LogParserCollector();
for (var run = 0; run < 6; run++)
collector.Execute(rule, _context);
var expected = Enumerable.Range(1, 30).Select(i => i.ToString("D3")).ToList();
Assert.Equal(expected, Captured("n"));
}
[Fact]
public void Line_written_in_two_parts_is_matched_once_and_whole()
{
var path = LogPath("partial.log");
File.WriteAllText(path, "ERR");
var rule = TextRule("RULE-PARTIAL", path, "^ERROR (?<code>0x[0-9A-F]+)$");
var collector = new LogParserCollector();
collector.Execute(rule, _context); // the writer is mid-line
File.AppendAllText(path, "OR 0x1F\n");
collector.Execute(rule, _context);
Assert.Equal(new List<string> { "0x1F" }, Captured("code"));
}
[Fact]
public void Interval_rule_reads_a_line_still_unfinished_on_its_second_run_once()
{
var path = LogPath("never-terminated.log");
File.WriteAllText(path, "ERROR 0x2A");
var rule = TextRule("RULE-UNFINISHED", path, "^ERROR (?<code>0x[0-9A-F]+)$");
var collector = new LogParserCollector();
collector.Execute(rule, _context); // held: the writer may still be in the line
collector.Execute(rule, _context); // still unfinished — taken as it is
collector.Execute(rule, _context); // nothing new
Assert.Equal(new List<string> { "0x2A" }, Captured("code"));
}
[Fact]
public void One_shot_rule_reads_an_unfinished_line_now_and_whole_on_its_next_run()
{
var path = LogPath("startup.log");
File.WriteAllText(path, "ok\nERR");
var rule = TextRule("RULE-STARTUP", path, "^ERR(?<rest>.*)$", trigger: "startup");
var collector = new LogParserCollector();
collector.Execute(rule, _context); // agent start: the writer is mid-line
File.AppendAllText(path, "OR 0x1F\n");
collector.Execute(rule, _context); // next agent start
// Read twice, never split: the second read sees the whole line.
Assert.Equal(new List<string> { "", "OR 0x1F" }, Captured("rest"));
Assert.Equal(new long[] { 2, 2 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
}
[Fact]
public void Line_feed_written_after_a_trailing_carriage_return_is_consumed_without_a_line()
{
var path = LogPath("cr-at-end.log");
File.WriteAllText(path, "hit\r");
var rule = TextRule("RULE-CR", path, "^hit$");
var collector = new LogParserCollector();
collector.Execute(rule, _context);
File.AppendAllText(path, "\n");
collector.Execute(rule, _context);
Assert.Single(_events);
var position = _context.SessionState.PeekLogPosition("RULE-CR", path);
Assert.Equal(new FileInfo(path).Length, position!.Position);
Assert.Equal(1, position.LineNumber);
}
[Fact]
public void Utf16_log_appended_after_the_first_run_is_still_matched()
{
var path = LogPath("transcript.log");
File.WriteAllText(path, "ERROR first\r\n", Encoding.Unicode); // UTF-16 LE with BOM
var rule = TextRule("RULE-UTF16", path, "^ERROR (?<what>\\w+)$");
var collector = new LogParserCollector();
collector.Execute(rule, _context);
using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write))
{
var bytes = new UnicodeEncoding(bigEndian: false, byteOrderMark: false).GetBytes("ERROR second\r\n");
stream.Write(bytes, 0, bytes.Length);
}
collector.Execute(rule, _context);
Assert.Equal(new List<string> { "first", "second" }, Captured("what"));
}
[Theory]
[InlineData("abc")]
[InlineData("0")]
[InlineData("-5")]
public void MaxLines_that_is_no_positive_number_falls_back_to_the_default(string maxLines)
{
var path = LogPath("misconfigured.log");
File.WriteAllText(path, "hit 1\nhit 2\n");
var rule = TextRule("RULE-MISCONFIGURED", path, "^hit (?<n>\\d)$");
rule.Parameters["maxLines"] = maxLines;
new LogParserCollector().Execute(rule, _context);
Assert.Equal(new List<string> { "1", "2" }, Captured("n"));
}
[Fact]
public void Line_numbers_count_from_the_start_of_the_file()
{
var path = LogPath("numbers.log");
File.WriteAllText(path, "a\nhit\nb\n");
var rule = TextRule("RULE-NUMBERS", path, "^hit$");
var collector = new LogParserCollector();
collector.Execute(rule, _context);
File.AppendAllText(path, "c\nhit\n");
collector.Execute(rule, _context);
Assert.Equal(new long[] { 2, 5 }, _events.Select(e => Convert.ToInt64(e.Data["logLineNumber"])).ToArray());
}
}
}
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
tenantId: "tenant",
onEventCollected: _ => { },
imeLogPathOverride: null,
filePositionTracker: new LogFilePositionTracker())
sessionState: new GatherRuleSessionState("sess", null, logger))
{
// Bypass the registry allowlist guard for the synthetic test path.
UnrestrictedMode = true
@@ -0,0 +1,71 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The completion timer a started Hello wizard runs against is the tenant's Hello wait
/// (<c>HelloWaitTimeoutSeconds</c>), resolved to 300..3600 s — the same budget the engine's
/// hello_safety window spends from the ESP exit, so a tenant allowing an hour is not cut off
/// by a fixed 5-min tracker timer while the user is still inside the wizard.
/// </summary>
public sealed class HelloTrackerCompletionBudgetTests
{
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
[Theory]
[InlineData(30, 300)] // the old default, still stored for many tenants → the built-in window
[InlineData(0, 300)]
[InlineData(300, 300)]
[InlineData(1800, 1800)]
[InlineData(3600, 3600)]
[InlineData(86400, 3600)] // clamped to the ceiling
public void Completion_budget_is_the_resolved_tenant_hello_wait(int configured, int expected)
{
using var tmp = new TempDirectory();
using var tracker = NewTracker(tmp, new FakeSignalIngressSink(), configured);
Assert.Equal(expected, tracker.CompletionTimeoutSecondsForTest);
}
[Fact]
public void Completion_timeout_event_reports_the_tenant_budget()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
using var tracker = NewTracker(tmp, ingress, helloWaitTimeoutSeconds: 2400);
tracker.NotifyHelloWizardStarted();
Assert.True(tracker.IsCompletionTimerActiveForTest);
tracker.TriggerCompletionTimeoutForTest();
var timeout = ingress.Posted.Single(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == Constants.EventTypes.HelloCompletionTimeout);
var data = Assert.IsAssignableFrom<IDictionary<string, object>>(timeout.TypedPayload);
Assert.Equal(2400, data["timeoutSeconds"]);
Assert.Equal("timeout", tracker.HelloOutcome);
}
private static HelloTracker NewTracker(TempDirectory tmp, FakeSignalIngressSink ingress, int helloWaitTimeoutSeconds) =>
new HelloTracker(
sessionId: "S1",
tenantId: "T1",
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds);
}
}
@@ -337,7 +337,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
"Launch powershell executor in machine session",
new[] { "context=machine" }
};
// Wording from the IME decompile (ContentDownloaderDeliveryOptimization, identical 1.97–1.105).
// IME wording, identical from 1.97 to 1.105.
yield return new object[]
{
"IME-DO-TIMEOUT-2",
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
/// completion markers (whiteglove.complete, clean-exit) — V1 sessions had only AgentLogs/
/// and ImeLogs/, which left forensics blind to decision-engine state and pending uploads.
/// </summary>
[Collection(UserProfileResolverCollection.Name)] // replaces UserProfileResolver's static state
public sealed class DiagnosticsPackageServiceTests
{
private static AgentConfiguration Cfg(string sessionId = "S1") => new AgentConfiguration
@@ -0,0 +1,96 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Serialises the test classes that replace <see cref="UserProfileResolver"/>'s static state.
/// </summary>
[CollectionDefinition(Name)]
public sealed class UserProfileResolverCollection
{
public const string Name = "UserProfileResolver";
}
/// <summary>
/// The agent starts before anyone has signed in. A lookup in that window must not pin
/// "no user" for the rest of the process — the %LOGGED_ON_USER_PROFILE% gather rules and the
/// RealmJoin user logs of the diagnostics package would never resolve.
/// </summary>
[Collection(UserProfileResolverCollection.Name)]
public sealed class UserProfileResolverTests : IDisposable
{
private static readonly DateTime Start = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
public void Dispose() => UserProfileResolver.Reset();
[Fact]
public void User_who_signs_in_after_the_first_lookup_is_found_later()
{
string? signedIn = null;
var now = Start;
UserProfileResolver.SetDetectorForTesting(() => signedIn, () => now);
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
signedIn = @"C:\Users\enduser";
now += UserProfileResolver.RetryInterval;
Assert.Equal(@"C:\Users\enduser", UserProfileResolver.GetLoggedOnUserProfilePath());
}
[Fact]
public void No_user_is_detected_again_only_after_the_retry_interval()
{
var detections = 0;
var now = Start;
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
UserProfileResolver.GetLoggedOnUserProfilePath();
now += TimeSpan.FromSeconds(10);
UserProfileResolver.GetLoggedOnUserProfilePath();
UserProfileResolver.GetLoggedOnUserProfilePath();
Assert.Equal(1, detections);
now += UserProfileResolver.RetryInterval;
UserProfileResolver.GetLoggedOnUserProfilePath();
Assert.Equal(2, detections);
}
[Fact]
public void A_clock_stepping_backwards_does_not_delay_the_next_detection()
{
var detections = 0;
var now = Start;
UserProfileResolver.SetDetectorForTesting(() => { detections++; return null; }, () => now);
UserProfileResolver.GetLoggedOnUserProfilePath();
now -= TimeSpan.FromHours(1); // OOBE time sync
UserProfileResolver.GetLoggedOnUserProfilePath();
Assert.Equal(2, detections);
}
[Fact]
public void A_found_user_is_kept_without_detecting_again()
{
var detections = 0;
UserProfileResolver.SetDetectorForTesting(() => { detections++; return @"C:\Users\enduser"; }, () => Start);
UserProfileResolver.GetLoggedOnUserProfilePath();
UserProfileResolver.GetLoggedOnUserProfilePath();
Assert.Equal(1, detections);
}
[Fact]
public void Pinned_no_user_stays_null_without_real_detection()
{
UserProfileResolver.SetForTesting(null!);
Assert.Null(UserProfileResolver.GetLoggedOnUserProfilePath());
Assert.Null(UserProfileResolver.ExpandCustomTokens(@"%LOGGED_ON_USER_PROFILE%\AppData\Local\x.log"));
}
}
}
@@ -61,7 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
// enforcement state to 1001 (verified against IME 1.106).
[Theory]
[InlineData(1000, "installed")]
[InlineData(1002, "installed")]
@@ -83,6 +83,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
using var f = new Fixture();
Assert.False(f.Tracker.HasAccountSetupActivity);
Assert.False(f.Tracker.HasAccountSetupProgress);
Assert.Null(f.Tracker.AccountSetupProgressIfRead);
}
[Fact]
@@ -97,6 +98,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.True(f.Tracker.HasAccountSetupActivity);
Assert.False(f.Tracker.HasAccountSetupProgress);
// Read and untouched — the only state the WhiteGlove sealing gate treats as "false".
Assert.False(f.Tracker.AccountSetupProgressIfRead);
}
[Fact]
@@ -108,6 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.True(f.Tracker.HasAccountSetupActivity);
Assert.True(f.Tracker.HasAccountSetupProgress);
Assert.True(f.Tracker.AccountSetupProgressIfRead);
}
[Theory]
@@ -138,5 +142,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.False(f.Tracker.HasAccountSetupProgress);
}
[Fact]
public void OnlyDeviceSetupRead_AccountSetupProgressIsUnknown()
{
// Adversarial for the WhiteGlove sealing gate: the tracker holds DeviceSetup data but
// has not read the AccountSetup category — that must be "unknown", never "untouched".
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status",
"{\"categoryState\":\"inProgress\",\"DeviceSetup.AppsSubcategory\":{\"subcategoryState\":\"inProgress\"}}");
Assert.Null(f.Tracker.AccountSetupProgressIfRead);
Assert.False(f.Tracker.HasAccountSetupProgress);
}
}
}
@@ -0,0 +1,123 @@
#nullable enable
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// A tenant-extended Hello wait runs up to 60 min — the default session_stalled threshold. While
/// it is pending the session waits on a bounded timer that resolves it either way, so the stall
/// probe holds the report back instead of flagging the session Stalled; once the wait is over
/// and the session is still idle the report fires, and real activity drops it. The collector
/// runs with no scan sources, so only the session_stalled decision is exercised.
/// </summary>
public sealed class StallProbeHelloWaitGuardTests
{
private static readonly DateTime Fixed = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
private const double IdleAtStallThreshold = 60;
[Fact]
public void Session_stalled_is_held_back_while_the_hello_wait_is_pending()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
var collector = NewCollector(tmp, ingress, helloWaitPending: () => true);
collector.CheckAndRunProbes(IdleAtStallThreshold);
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
Assert.Equal(0, StalledCount(ingress));
}
[Fact]
public void Held_back_report_fires_once_the_hello_wait_is_over_and_the_session_is_still_idle()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
var pending = true;
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
collector.CheckAndRunProbes(IdleAtStallThreshold);
Assert.Equal(0, StalledCount(ingress));
// hello_safety resolved (a deadline fire is no activity, so no ResetProbes): the next
// tick emits the held-back report — once.
pending = false;
collector.CheckAndRunProbes(IdleAtStallThreshold + 1);
collector.CheckAndRunProbes(IdleAtStallThreshold + 2);
Assert.Equal(1, StalledCount(ingress));
}
[Fact]
public void Real_activity_drops_the_held_back_report()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
var pending = true;
var collector = NewCollector(tmp, ingress, helloWaitPending: () => pending);
collector.CheckAndRunProbes(IdleAtStallThreshold);
pending = false;
collector.ResetProbes();
collector.CheckAndRunProbes(1);
Assert.Equal(0, StalledCount(ingress));
// A new idle window reaches the threshold again — the fire-once report is still unused.
collector.CheckAndRunProbes(IdleAtStallThreshold);
Assert.Equal(1, StalledCount(ingress));
}
[Fact]
public void Without_a_hello_wait_probe_session_stalled_fires_as_before()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
var collector = NewCollector(tmp, ingress, helloWaitPending: null);
collector.CheckAndRunProbes(IdleAtStallThreshold);
Assert.Equal(1, StalledCount(ingress));
}
[Fact]
public void A_throwing_hello_wait_probe_never_costs_the_stall_report()
{
using var tmp = new TempDirectory();
var ingress = new FakeSignalIngressSink();
var collector = NewCollector(tmp, ingress, helloWaitPending: () => throw new InvalidOperationException("probe down"));
collector.CheckAndRunProbes(IdleAtStallThreshold);
Assert.Equal(1, StalledCount(ingress));
}
private static StallProbeCollector NewCollector(TempDirectory tmp, FakeSignalIngressSink ingress, Func<bool>? helloWaitPending) =>
new StallProbeCollector(
sessionId: "S1",
tenantId: "T1",
post: new InformationalEventPost(ingress, new VirtualClock(Fixed)),
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info),
thresholdsMinutes: new[] { 60 },
traceIndices: Array.Empty<int>(),
sources: Array.Empty<string>(),
sessionStalledAfterProbeIndex: 1,
helloWaitPending: helloWaitPending);
private static int StalledCount(FakeSignalIngressSink ingress) =>
ingress.Posted.Count(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == Constants.EventTypes.SessionStalled);
}
}
@@ -20,6 +20,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
private sealed class FakeApiClient : BackendApiClient
{
public readonly List<TimeSpan?> Timeouts = new List<TimeSpan?>();
public readonly List<AgentErrorReport> Reports = new List<AgentErrorReport>();
public readonly Queue<bool> Results = new Queue<bool>();
public FakeApiClient() : base(
@@ -40,6 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
public override Task<bool> ReportAgentErrorAsync(AgentErrorReport report, TimeSpan? timeout = null)
{
Timeouts.Add(timeout);
Reports.Add(report);
return Task.FromResult(Results.Count > 0 && Results.Dequeue());
}
}
@@ -139,5 +141,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
Assert.Equal(2, client.Calls);
}
[Fact]
public async Task Session_age_is_carried_in_the_report_and_absent_by_default()
{
// One reporter per report: a delivered report starts the 10-minute cooldown.
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
await Reporter(client).TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m", sessionAgeHours: 478.2);
await Reporter(client).TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
Assert.Equal(2, client.Reports.Count);
Assert.Equal(478.2, client.Reports[0].SessionAgeHours);
Assert.Null(client.Reports[1].SessionAgeHours);
}
[Fact]
public async Task Prior_registration_failure_is_carried_in_the_report_and_absent_by_default()
{
var client = new FakeApiClient();
client.Results.Enqueue(true);
client.Results.Enqueue(true);
var record = new RegistrationFailureSummary { FailedRuns = 2, Outcome = "Failed", NetworkLinkUpAtEnd = false };
await Reporter(client).TrySendAsync(AgentErrorType.SessionAgeEmergencyBreak, "m", priorRegistrationFailure: record);
await Reporter(client).TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
Assert.Equal(2, client.Reports.Count);
Assert.Same(record, client.Reports[0].PriorRegistrationFailure);
Assert.Null(client.Reports[1].PriorRegistrationFailure);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// The production factory hands its state directory to the gather executor. Without it every
/// unit test of the gather state passes while the agent keeps replaying logs after each reboot —
/// the one line in <see cref="DefaultComponentFactory"/> is the whole feature.
/// </summary>
[Collection("SerialThreading")] // the startup rule executes on the shared ThreadPool
public sealed class DefaultComponentFactoryGatherStateTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
private readonly TempDirectory _stateDir = new TempDirectory();
// %TEMP% lives under C:\Users (hard-blocked) — the log sits beside the test assembly.
private readonly string _logDir = Path.Combine(
AppContext.BaseDirectory, "factory-gather-state-" + Guid.NewGuid().ToString("N"));
public DefaultComponentFactoryGatherStateTests() => Directory.CreateDirectory(_logDir);
public void Dispose()
{
_tmp.Dispose();
_stateDir.Dispose();
try { Directory.Delete(_logDir, recursive: true); } catch { /* best effort */ }
}
[Fact]
public void Gather_host_built_by_the_factory_persists_logparser_positions_in_the_state_directory()
{
var log = Path.Combine(_logDir, "vendor.log");
File.WriteAllText(log, "Update one\n");
var factory = new DefaultComponentFactory(
agentConfig: new AgentConfiguration
{
ApiBaseUrl = "https://example",
TenantId = "t",
SessionId = "s",
UnrestrictedMode = true,
},
remoteConfig: new AgentConfigResponse
{
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new List<GatherRule>
{
new GatherRule
{
RuleId = "vendor-log",
Title = "vendor log",
CollectorType = "logparser",
Target = log,
Parameters = new Dictionary<string, string> { ["pattern"] = "^Update", ["format"] = "text" },
Trigger = "startup",
OutputEventType = "vendor_update",
Enabled = true,
},
},
},
networkMetrics: null,
agentVersion: "test",
stateDirectory: _stateDir.Path,
startupEventGate: null);
var hosts = factory.CreateCollectorHosts(
sessionId: "s",
tenantId: "t",
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
whiteGloveSealingPatternIds: Array.Empty<string>(),
ingress: new FakeSignalIngressSink(),
clock: new VirtualClock(new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc)),
telemetrySpool: null,
timelineEvents: null).Hosts;
try
{
hosts.OfType<GatherRuleExecutorHost>().Single().Start();
var stateFile = Path.Combine(_stateDir.Path, GatherRuleStatePersistence.FileName);
Assert.True(SpinWait.SpinUntil(() => File.Exists(stateFile), TimeSpan.FromSeconds(10)),
"the gather host did not write its state into the factory's state directory");
}
finally
{
foreach (var host in hosts)
{
try { host.Dispose(); } catch { /* best-effort cleanup */ }
}
}
}
}
}
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// <summary>
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
/// signals. Regression anchor: session 32312a32 — a phase_change rule on
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
@@ -162,6 +162,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.Equal(SessionStage.AwaitingHello, result.InitialState.Stage);
}
[Fact]
public void Clean_snapshot_of_a_rotated_session_adopts_the_current_session_id()
{
// SESSION-OWNER-BINDING: the backend refused the persisted session id at registration,
// the agent rotated, and the snapshot on disk still names the old session. The
// enrollment state carries over; the session id must be the current one.
using var rig = new Rig();
var saved = DecisionState.CreateInitial("S-REFUSED", TenantId)
.ToBuilder()
.WithStage(SessionStage.EspAccountSetup)
.WithStepIndex(7)
.WithLastAppliedSignalOrdinal(6)
.Build();
SeedSnapshot(rig, saved);
var result = rig.Recover();
Assert.Equal(SessionId, result.InitialState.SessionId);
Assert.Equal(SessionStage.EspAccountSetup, result.InitialState.Stage);
Assert.Equal(7, result.InitialState.StepIndex);
}
[Fact]
public void Snapshot_plus_pending_tail_replays_tail_onto_snapshot()
{
@@ -118,5 +118,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Throws<ArgumentNullException>(
() => BackendSessionRegistration.Register(config, auth, http, "1.0", consoleMode: false, logger: null));
}
// ============================================================ failed-registration record
[Fact]
public void RecordRegistrationRun_records_a_failed_run_with_its_window_and_bounded_error()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var started = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
BackendSessionRegistration.RecordRegistrationRun(
persistence, SessionRegistrationResult.Failed(new string('e', 1000)),
started, started.AddSeconds(1204.46), linkUpAtStart: false, linkUpAtEnd: true,
configFetchOutcome: "UsedDefaults", logger: NewLogger(tmp.Path));
var record = persistence.LoadRegistrationFailure();
Assert.NotNull(record);
Assert.Equal(1, record!.FailedRuns);
Assert.Equal("Failed", record.Outcome);
Assert.Equal(BackendSessionRegistration.MaxRecordedErrorLength, record.LastError!.Length);
Assert.False(record.NetworkLinkUpAtStart);
Assert.True(record.NetworkLinkUpAtEnd);
Assert.Equal(1204.5, record.AttemptWindowSeconds);
Assert.Equal("UsedDefaults", record.ConfigFetchOutcome);
Assert.Equal(started.AddSeconds(1204.46), record.LastFailedAtUtc);
}
[Fact]
public void RecordRegistrationRun_records_auth_failures_and_clears_on_success()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
BackendSessionRegistration.RecordRegistrationRun(
persistence, SessionRegistrationResult.AuthFailed(401, "unauthorized"),
at, at.AddSeconds(3), true, true, "Succeeded", NewLogger(tmp.Path));
Assert.Equal("AuthFailed", persistence.LoadRegistrationFailure()!.Outcome);
BackendSessionRegistration.RecordRegistrationRun(
persistence, SessionRegistrationResult.Succeeded(new RegisterSessionResponse { Success = true }),
at, at.AddSeconds(1), true, true, "Succeeded", NewLogger(tmp.Path));
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void RecordRegistrationRun_without_persistence_is_a_no_op()
{
using var tmp = new TempDirectory();
BackendSessionRegistration.RecordRegistrationRun(
null, SessionRegistrationResult.Failed("x"), DateTime.UtcNow, DateTime.UtcNow,
null, null, null, NewLogger(tmp.Path));
}
// ============================================================ emergency-break message
[Fact]
public void Break_message_names_failed_registrations_in_invariant_culture()
{
var previous = System.Threading.Thread.CurrentThread.CurrentCulture;
try
{
System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
var record = new RegistrationFailureSummary { FailedRuns = 3, Outcome = "Failed", NetworkLinkUpAtEnd = false };
var message = EmergencyBreakReporter.BuildMessage(478.2, 48, record);
Assert.Contains("session age 478.2h (cap 48h)", message);
Assert.EndsWith(" 3 earlier start(s) failed to register (last: Failed, network link at end: down).", message);
}
finally
{
System.Threading.Thread.CurrentThread.CurrentCulture = previous;
}
}
[Fact]
public void Break_message_without_record_keeps_the_historical_text()
{
Assert.Equal(
"Agent absolute session-age emergency break fired at session age 50.0h (cap 48h) — cleaning up and exiting.",
EmergencyBreakReporter.BuildMessage(50.0, 48, null));
}
}
}
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -29,7 +30,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var sink = new FakeSignalIngressSink();
var logger = NewLogger(tmp.Path);
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
Assert.Single(sink.Posted);
var posted = sink.Posted[0];
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var sink = new FakeSignalIngressSink();
var logger = NewLogger(tmp.Path);
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger);
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger);
var payload = sink.Posted[0].Payload;
Assert.NotNull(payload);
@@ -73,6 +74,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.True(cloudPc == "true" || cloudPc == "false");
}
[Theory]
[InlineData(300)] // the default — the payload stays exactly what it was before the key existed
[InlineData(30)] // the old default, still stored for many tenants
[InlineData(0)]
public void Hello_wait_not_extending_the_built_in_window_is_not_stamped(int helloWaitTimeoutSeconds)
{
using var tmp = new TempDirectory();
var sink = new FakeSignalIngressSink();
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
Assert.DoesNotContain(SignalPayloadKeys.HelloWaitTimeoutSeconds, (IDictionary<string, string>)sink.Posted[0].Payload!);
}
[Theory]
[InlineData(1800, "1800")]
[InlineData(3600, "3600")]
[InlineData(86400, "3600")] // the effective (clamped) value is what the engine should arm
public void Extended_hello_wait_is_stamped_as_its_effective_value(int helloWaitTimeoutSeconds, string expected)
{
using var tmp = new TempDirectory();
var sink = new FakeSignalIngressSink();
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, helloWaitTimeoutSeconds, NewLogger(tmp.Path));
Assert.Equal(expected, sink.Posted[0].Payload![SignalPayloadKeys.HelloWaitTimeoutSeconds]);
}
[Fact]
public void Swallows_sink_exceptions()
{
@@ -82,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var logger = NewLogger(tmp.Path);
var ex = Record.Exception(() =>
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, logger));
AutopilotMonitor.Agent.V2.Runtime.LifecycleEmitters.PostEnrollmentFactsObserved(sink, HelloWaitTimeout.DefaultSeconds, logger));
Assert.Null(ex);
}
@@ -149,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-100));
var stateDir = Path.Combine(tmp.Path, "State");
double? reportedAgeHours = null;
var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak(
dataDirectory: tmp.Path,
@@ -157,9 +158,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
selfDestructOnComplete: false,
cleanupServiceFactory: null,
logger: logger,
consoleMode: false);
consoleMode: false,
onBreakFired: ageHours => reportedAgeHours = ageHours);
Assert.True(tripped);
// The measured age reaches the break report (the backend cannot derive it).
Assert.NotNull(reportedAgeHours);
Assert.InRange(reportedAgeHours.Value, 99.9, 100.1);
// Marker must have been written so the next restart exits cleanly.
Assert.True(File.Exists(Path.Combine(stateDir, "enrollment-complete.marker")));
// Session must have been cleared.
@@ -3,6 +3,7 @@ using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
@@ -234,5 +235,78 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.False(File.Exists(Path.Combine(tmp.Path, "session.created")));
Assert.False(File.Exists(Path.Combine(tmp.Path, "whiteglove.complete")));
}
// ============================================================ failed-registration record
private static RegistrationFailureSummary FailedRun(DateTime at, string outcome = "Failed", bool? linkUpAtEnd = false) =>
new RegistrationFailureSummary
{
FailedRuns = 1,
FirstFailedAtUtc = at,
LastFailedAtUtc = at,
Outcome = outcome,
LastError = "No such host is known.",
NetworkLinkUpAtStart = false,
NetworkLinkUpAtEnd = linkUpAtEnd,
AttemptWindowSeconds = 91.3,
ConfigFetchOutcome = "UsedDefaults",
};
[Fact]
public void RecordRegistrationFailure_counts_runs_keeps_the_first_time_and_describes_the_latest_run()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
var first = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var second = new DateTime(2026, 9, 12, 7, 30, 0, DateTimeKind.Utc);
Assert.Null(sut.LoadRegistrationFailure());
sut.RecordRegistrationFailure(FailedRun(first));
sut.RecordRegistrationFailure(FailedRun(second, outcome: "AuthFailed", linkUpAtEnd: true));
// A fresh instance reads it back from disk, as the next boot's emergency break does.
var record = new SessionIdPersistence(tmp.Path).LoadRegistrationFailure();
Assert.NotNull(record);
Assert.Equal(2, record!.FailedRuns);
Assert.Equal(first, record.FirstFailedAtUtc);
Assert.Equal(second, record.LastFailedAtUtc);
Assert.Equal("AuthFailed", record.Outcome);
Assert.True(record.NetworkLinkUpAtEnd);
Assert.Equal(91.3, record.AttemptWindowSeconds);
Assert.Equal("UsedDefaults", record.ConfigFetchOutcome);
}
[Fact]
public void Registration_failure_record_is_cleared_by_success_delete_and_rotate()
{
using var tmp = new TempDirectory();
var sut = new SessionIdPersistence(tmp.Path);
sut.GetOrCreate();
var at = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
sut.RecordRegistrationFailure(FailedRun(at));
sut.ClearRegistrationFailure();
Assert.Null(sut.LoadRegistrationFailure());
sut.RecordRegistrationFailure(FailedRun(at));
sut.Rotate();
Assert.Null(sut.LoadRegistrationFailure());
sut.RecordRegistrationFailure(FailedRun(at));
sut.Delete();
Assert.Null(sut.LoadRegistrationFailure());
Assert.False(File.Exists(Path.Combine(tmp.Path, "registration-failure.json")));
}
[Theory]
[InlineData("not json")]
[InlineData("{\"FailedRuns\":0}")]
public void LoadRegistrationFailure_reports_nothing_for_a_corrupt_or_empty_record(string content)
{
using var tmp = new TempDirectory();
File.WriteAllText(Path.Combine(tmp.Path, "registration-failure.json"), content);
Assert.Null(new SessionIdPersistence(tmp.Path).LoadRegistrationFailure());
}
}
}
@@ -137,6 +137,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
}
private static BackendRefusalException TenantConflict()
=> new BackendRefusalException("Backend returned 409 Conflict with error code session_owner_mismatch.", 409,
AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch);
[Fact]
public async Task Tenant_conflict_409_rotates_once_like_an_owner_mismatch()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var originalSessionId = config.SessionId;
var rotatedTo = Guid.NewGuid().ToString();
var seenSessionIds = new System.Collections.Generic.List<string>();
var apiClient = new FakeApiClient(
attempt => attempt == 1
? throw TenantConflict()
: new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 },
onRegister: r => seenSessionIds.Add(r.SessionId));
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { config.SessionId = rotatedTo; return rotatedTo; });
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
Assert.Equal(0, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Coded_refusal_after_rotation_fails_without_retry_or_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw TenantConflict());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker,
backoffDelay: _ => throw new InvalidOperationException("a coded refusal must not be retried"),
networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; });
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal(2, apiClient.CallCount);
Assert.Equal(0, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Rotation_hook_failure_on_a_tenant_conflict_fails_without_auth_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ => throw TenantConflict());
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
clock: SystemClock.Instance, logger: logger);
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask,
rotateSession: () => throw new IOException("disk full"));
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal(1, apiClient.CallCount);
Assert.Equal(0, tracker.ConsecutiveFailures);
}
[Fact]
public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure()
{
@@ -99,6 +99,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, posted.Kind);
}
[Theory]
[InlineData(true, "true")]
[InlineData(false, "false")]
public void WhiteGloveEvent_stamps_read_AccountSetup_progress_on_payload_and_evidence(bool progress, string expected)
{
using var f = new Fixture();
using var adapter = new EspAndHelloTrackerAdapter(
f.Coordinator, f.Ingress, f.Clock, accountSetupProgressProbe: () => progress);
adapter.TriggerWhiteGloveFromTest();
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(expected, posted.Payload![SignalPayloadKeys.AccountSetupProgress]);
Assert.Equal(expected, posted.Evidence.DerivationInputs![SignalPayloadKeys.AccountSetupProgress]);
}
[Fact]
public void WhiteGloveEvent_with_unread_AccountSetup_registry_sends_no_progress_key()
{
// Unknown must keep the sealing classifier's AccountSetup excluder — the payload key
// is absent, the evidence records why.
using var f = new Fixture();
using var adapter = new EspAndHelloTrackerAdapter(
f.Coordinator, f.Ingress, f.Clock, accountSetupProgressProbe: () => null);
adapter.TriggerWhiteGloveFromTest();
var posted = Assert.Single(f.Ingress.Posted);
Assert.True(posted.Payload == null || !posted.Payload.ContainsKey(SignalPayloadKeys.AccountSetupProgress));
Assert.Equal("unread", posted.Evidence.DerivationInputs![SignalPayloadKeys.AccountSetupProgress]);
}
[Fact]
public void WhiteGloveEvent_default_probe_on_unstarted_coordinator_reports_unread()
{
// Production probe: the provisioning tracker does not exist before Start (and never on
// Device Preparation) — that is "unread", not "untouched".
using var f = new Fixture();
using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock);
adapter.TriggerWhiteGloveFromTest();
var posted = Assert.Single(f.Ingress.Posted);
Assert.True(posted.Payload == null || !posted.Payload.ContainsKey(SignalPayloadKeys.AccountSetupProgress));
}
[Fact]
public void EspFailureEvent_emits_EspTerminalFailure_with_merged_subSource_annotation()
{
@@ -564,6 +564,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
}
[Fact]
public void Handle_whiteglove_part1_writes_marker_before_the_later_exit_steps()
{
// The technician's reseal can kill the process anywhere after the seal. The marker
// is what lets the next boot skip the 48-hour break and resume Part 2, so it must
// exist before collectors stop, diagnostics upload, grace and drain run.
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
bool? markerPresentAtCollectorStop = null;
rig.StopPeripheralCollectorsHook = () => markerPresentAtCollectorStop = rig.SessionPersistence.IsWhiteGloveResume();
rig.Build().Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
Assert.True(markerPresentAtCollectorStop, "whiteglove.complete must be written before the peripheral collectors are stopped.");
var emitted = rig.EmittedEventTypes.ToList();
Assert.True(emitted.IndexOf("agent_shutting_down") >= 0, "agent_shutting_down must still be emitted.");
}
[Fact]
public void Handle_non_whiteglove_termination_never_writes_the_marker()
{
using var rig = new Rig();
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
rig.Build().Handle(sender: null!,
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
Assert.False(rig.SessionPersistence.IsWhiteGloveResume());
}
[Fact]
public void Handle_whiteglove_part1_stops_collectors_before_emitting_part1_complete()
{
@@ -88,6 +88,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Null(ex.ErrorCode);
}
// ── coded 409: a refusal, never an auth failure ─────────────────────
[Fact]
public async Task Coded_409_is_a_refusal_not_an_auth_failure()
{
using var client = BuildClient(
HttpStatusCode.Conflict,
"{\"success\":false,\"message\":\"Session id is already registered to another tenant\",\"errorCode\":\"session_owner_mismatch\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendRefusalException>(
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
Assert.Equal(409, ex.StatusCode);
Assert.Equal("session_owner_mismatch", ex.ErrorCode);
}
[Fact]
public async Task Uncoded_409_stays_a_plain_http_error()
{
using var client = BuildClient(
HttpStatusCode.Conflict,
"{\"success\":false,\"message\":\"A diagnostics package with this name already exists\"}",
"application/json");
await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
@@ -157,12 +157,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
public double ReplaySpeedFactor { get; set; } = 50;
/// <summary>
/// Wait time in seconds after ESP exit before marking Hello as skipped.
/// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
/// Default: 30 seconds (reasonable for systems under load).
/// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
/// Tenant Hello wait in seconds after the ESP exit, merged from RemoteConfig. Stamped on
/// <c>EnrollmentFactsObserved</c> for the decision engine's hello_safety window; the Hello
/// tracker gets the same remote value through the collector configuration. Both resolve it
/// through <see cref="HelloWaitTimeout.EffectiveSeconds"/> (at least the built-in 300 s,
/// at most 3600 s). Default: 300 seconds.
/// </summary>
public int HelloWaitTimeoutSeconds { get; set; } = 30;
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
/// <summary>
/// Maximum consecutive authentication failures (401/403) before the agent shuts down.
@@ -58,8 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
/// decompiled build). Within one process every file shares the zone belief, so a local
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified against
/// the IME build). Within one process every file shares the zone belief, so a local
/// timestamp places the line in the service era whose local range contains it. Disabled
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
@@ -4,9 +4,9 @@ using System.Collections.Generic;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Tracks file read positions for incremental log file reading.
/// In-memory only - resets on agent restart, which is acceptable since
/// we start fresh per enrollment session.
/// Tracks the IME log tracker's file read positions for incremental log file reading.
/// The bookmarks survive agent restarts through the tracker's own state file
/// (<see cref="GetAllPositions"/> / <see cref="RestorePosition"/>); the freshness timestamps do not.
/// </summary>
public class LogFilePositionTracker
{
@@ -216,6 +216,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
public bool HasAccountSetupProgress => _provisioningTracker?.HasAccountSetupProgress ?? false;
/// <summary>
/// <see cref="ProvisioningStatusTracker.AccountSetupProgressIfRead"/>; <c>null</c> also when
/// the provisioning tracker is not running (Device Preparation, before Start). Stamped on
/// the WhiteGloveShellCoreSuccess signal for the sealing classifier.
/// </summary>
public bool? AccountSetupProgressIfRead => _provisioningTracker?.AccountSetupProgressIfRead;
/// <summary>
/// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run).
/// Forwarded from ModernDeploymentTracker.
@@ -234,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
string tenantId,
InformationalEventPost post,
AgentLogger logger,
int helloWaitTimeoutSeconds = 30,
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds,
bool modernDeploymentWatcherEnabled = true,
int modernDeploymentLogLevelMax = 3,
bool modernDeploymentBackfillEnabled = true,
@@ -47,7 +47,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal const string HResult_UserSkippedHello = "0x801C044F";
internal const int HelloCompletionTimeoutSeconds = 300;
internal const int BackfillLookbackMinutes = 5;
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
@@ -81,6 +80,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly InformationalEventPost _post;
private readonly int _helloWaitTimeoutSeconds;
// How long a started Hello wizard may take to reach a terminal event (300/301/362): the
// tenant's Hello wait resolved through HelloWaitTimeout (300..3600 s). The engine's
// hello_safety window spends the same budget from the ESP exit, so this timer never cuts
// a user off inside the wizard before the tenant's budget is used up.
private readonly int _helloCompletionTimeoutSeconds;
private EventLogWatcher _udrWatcher;
private EventLogWatcher _helloForBusinessWatcher;
private System.Threading.Timer _policyCheckTimer;
@@ -121,13 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
string tenantId,
InformationalEventPost post,
AgentLogger logger,
int helloWaitTimeoutSeconds = 30)
int helloWaitTimeoutSeconds = HelloWaitTimeout.DefaultSeconds)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
_helloCompletionTimeoutSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
}
// =====================================================================
@@ -1094,7 +1100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (policyKnownEnabled)
{
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
$"wizard not yet visible, starting long completion timer ({_helloCompletionTimeoutSeconds}s)");
_post.Emit(new EnrollmentEvent
{
@@ -1105,7 +1111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
$"Hello policy is enabled, waiting up to {_helloCompletionTimeoutSeconds}s for wizard",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
@@ -1205,11 +1211,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (_isHelloCompleted) return;
if (_helloCompletionTimer != null) return;
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
_logger.Info($"Starting Hello completion timer ({_helloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
_helloCompletionTimer = new System.Threading.Timer(
OnHelloCompletionTimeout,
null,
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
TimeSpan.FromSeconds(_helloCompletionTimeoutSeconds),
TimeSpan.FromMilliseconds(-1));
}
@@ -1230,7 +1236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return;
}
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
_logger.Warning($"Hello completion timeout ({_helloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
_isHelloCompleted = true;
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
StopHelloCompletionTimerLocked();
@@ -1243,10 +1249,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
Severity = EventSeverity.Warning,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {_helloCompletionTimeoutSeconds}s",
Data = new Dictionary<string, object>
{
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
{ "timeoutSeconds", _helloCompletionTimeoutSeconds },
{ "helloWizardStarted", _helloWizardStarted }
},
ImmediateUpload = true
@@ -1304,6 +1310,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null);
internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null);
internal int CompletionTimeoutSecondsForTest => _helloCompletionTimeoutSeconds;
internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } }
internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } }
internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } }
@@ -360,7 +360,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
: (DateTime?)null;
}
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
/// <summary>StateMessageEnforcementState bands (verified against IME 1.97/1.104).</summary>
internal static string ClassifyEnforcementState(int state)
{
if (state >= 1000 && state < 2000) return "success";
@@ -254,16 +254,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// host to tell the real user-ESP page exit from the Device→Account handoff exit; not a
/// completion input.
/// </summary>
public bool HasAccountSetupProgress
public bool HasAccountSetupProgress => AccountSetupProgressIfRead == true;
/// <summary>
/// Tri-state form of <see cref="HasAccountSetupProgress"/>: <c>null</c> until the tracker
/// has actually read AccountSetup subcategories (not started yet, category not written,
/// empty), otherwise whether one has left <c>notStarted</c>. The WhiteGlove sealing
/// classifier treats only a read <c>false</c> as "user ESP untouched" — an unread
/// registry must never pass for an untouched one.
/// </summary>
public bool? AccountSetupProgressIfRead
{
get
{
lock (_stateLock)
{
if (_lastSubcategoryStates == null)
return false;
if (!_lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) || subs == null)
return false;
return null;
if (!_lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs)
|| subs == null
|| subs.Count == 0)
return null;
foreach (var state in subs.Values)
{
if (!string.Equals(state, "notStarted", StringComparison.OrdinalIgnoreCase))
@@ -279,7 +290,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// with subcategoryState=succeeded. NOT a WhiteGlove indicator: on Win11 25H2 (build
/// 26200+) Windows writes this property as a trivially-succeeding step in EVERY
/// enrollment (validated 2026-08-18 — user-driven sessions f475e697/c601a24b/4219203f
/// all carry it). Kept for observability only (whiteglove_signal_* raw dumps); must not
/// all carry it). Kept for observability only (save_result_step_* raw dumps); must not
/// be used for classification. Reliable WhiteGlove anchors are ModernDeployment
/// EventID 509 and the Shell-Core WhiteGlove-success events.
/// </summary>
@@ -966,8 +977,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_logger.Info($"ProvisioningStatusTracker: SaveWhiteGloveSuccessResult state " +
$"transition: {previousState} -> {state}");
// Neutral trigger name: the step exists in every 25H2 enrollment,
// and "whiteglove_*" was read as a pre-provisioning signal.
EmitRawRegistryDump(categoryLabel, jsonValue,
$"whiteglove_signal_{state}");
$"save_result_step_{state}");
if (string.Equals(state, "succeeded", StringComparison.OrdinalIgnoreCase))
{
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning
/// (held back while a tenant-extended Hello wait is pending, emitted once it is over).
/// </summary>
public class StallProbeCollector
{
@@ -54,10 +55,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
private readonly bool _isDevicePreparation;
// True while a tenant-extended Hello wait (up to 60 min) is pending in the decision
// engine. It is a bounded wait that resolves the session either way — not a stall — so
// session_stalled is held back until it is over.
private readonly Func<bool> _helloWaitPending;
private readonly object _stateLock = new object();
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
private bool _sessionStalledFired;
// The session_stalled report held back while the Hello wait was pending (the probe result
// of that idle window). Emitted on a later tick once the wait is over and the session is
// still idle; dropped by real activity (ResetProbes).
private ProbeResult _deferredSessionStalled;
// P2 — monotonic generation, bumped by ResetProbes. RunProbe runs OUTSIDE _stateLock; if a
// real-activity reset lands while a probe is in flight, the post-probe "mark fired" must be
// dropped (its generation is stale) so the next idle period can fire that probe again.
@@ -153,9 +164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
string[] sources,
int sessionStalledAfterProbeIndex,
int[] harmlessModernDeploymentEventIds = null,
bool isDevicePreparation = false)
bool isDevicePreparation = false,
Func<bool> helloWaitPending = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_helloWaitPending = helloWaitPending;
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -192,6 +205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// Always bump the generation, even when no probes are currently marked fired — a
// probe may be running right now (outside the lock) and must not re-mark itself.
_probeGeneration++;
_deferredSessionStalled = null;
if (_firedProbeIndices.Count > 0)
{
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
@@ -206,6 +220,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
public void CheckAndRunProbes(double idleMinutes)
{
EmitDeferredSessionStalledIfDue(idleMinutes);
// Iterate thresholds from lowest to highest so probes fire in order.
for (int i = 0; i < _thresholdsMinutes.Length; i++)
{
@@ -225,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (alreadyFired)
continue;
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
RunProbe(probeIndex, thresholdMinutes, idleMinutes, genAtStart);
lock (_stateLock)
{
@@ -237,7 +253,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes, int generation)
{
var started = DateTime.UtcNow;
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
@@ -293,8 +309,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// session_stalled is fire-once across the whole session. It fires at the configured
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
// While a tenant Hello wait is pending it is held back, not dropped.
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
{
if (IsHelloWaitPending())
{
lock (_stateLock)
{
// P2: a reset that landed while this probe ran ended the idle window.
if (!_sessionStalledFired && _probeGeneration == generation)
_deferredSessionStalled = result;
}
_logger.Info($"StallProbeCollector: session_stalled held back at probe {probeIndex} — a tenant Hello wait is still pending");
return;
}
lock (_stateLock)
{
if (_sessionStalledFired)
@@ -305,6 +334,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// Emits the held-back session_stalled report once the Hello wait is over and the session
/// is still idle (no ResetProbes since), with the idle time of this tick.
/// </summary>
private void EmitDeferredSessionStalledIfDue(double idleMinutes)
{
ProbeResult deferred;
lock (_stateLock)
{
deferred = _deferredSessionStalled;
if (deferred == null || _sessionStalledFired)
return;
}
if (IsHelloWaitPending())
return;
lock (_stateLock)
{
if (_sessionStalledFired || !ReferenceEquals(_deferredSessionStalled, deferred))
return;
_sessionStalledFired = true;
_deferredSessionStalled = null;
}
deferred.IdleMinutes = Math.Round(idleMinutes, 1);
_logger.Info($"StallProbeCollector: Hello wait over, still idle at {deferred.IdleMinutes}min — emitting the held-back session_stalled");
EmitSessionStalledEvent(deferred);
}
private bool IsHelloWaitPending()
{
if (_helloWaitPending == null)
return false;
try
{
return _helloWaitPending();
}
catch (Exception ex)
{
// A failing probe must never cost the stall report — treat as not pending.
_logger.Warning($"StallProbeCollector: Hello-wait probe threw, treating as not pending: {ex.Message}");
return false;
}
}
private void RunWithTimeout(Action<ProbeResult> scan, int timeoutMs, string sourceName, ProbeResult result)
{
// Enforce a real hard timeout: run the source on a dedicated background thread and
@@ -472,7 +472,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Expand BEFORE the guard: the guard must judge the path that is read,
// and an unresolved token is not a path it can judge.
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path, userProfilePath);
if (expandedPath == null)
{
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -14,7 +15,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
/// detects the real user via explorer.exe ownership (WTS session query, WMI fallback —
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the result for the agent's lifetime.
/// see <see cref="Interop.ProcessOwnerLookup"/>) and caches the first user it finds for the
/// agent's lifetime. "No user yet" is not cached: the agent usually starts before anyone has
/// signed in, so detection is retried — at most every <see cref="RetryInterval"/>.
///
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
/// </summary>
@@ -23,9 +26,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
/// <summary>Minimum time between two detections while no user has been found.</summary>
internal static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(30);
private static readonly object Lock = new object();
private static bool _resolved;
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null until a user is detected
private static DateTime _lastFailedDetectionUtc = DateTime.MinValue;
// Seams for tests; production uses the real detection and clock.
private static Func<string> _detector = DetectLoggedOnUserProfile;
private static Func<DateTime> _utcNow = () => DateTime.UtcNow;
/// <summary>
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
@@ -34,17 +44,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static string GetLoggedOnUserProfilePath()
{
if (_resolved)
return _userProfilePath;
var cached = Volatile.Read(ref _userProfilePath);
if (cached != null)
return cached;
lock (Lock)
{
if (_resolved)
if (_userProfilePath != null)
return _userProfilePath;
_userProfilePath = DetectLoggedOnUserProfile();
_resolved = true;
return _userProfilePath;
// A clock that stepped backwards (common during OOBE) never delays the retry.
var now = _utcNow();
var sinceLastFailure = now - _lastFailedDetectionUtc;
if (_lastFailedDetectionUtc != DateTime.MinValue
&& sinceLastFailure >= TimeSpan.Zero && sinceLastFailure < RetryInterval)
return null;
var detected = _detector();
if (detected != null)
Volatile.Write(ref _userProfilePath, detected);
else
_lastFailedDetectionUtc = now;
return detected;
}
}
@@ -53,18 +74,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// Environment.ExpandEnvironmentVariables for standard tokens.
/// Returns null if the path contains the token but no user is logged on.
/// </summary>
public static string ExpandCustomTokens(string rawPath)
public static string ExpandCustomTokens(string rawPath) =>
ExpandCustomTokens(rawPath, ContainsUserProfileToken(rawPath) ? GetLoggedOnUserProfilePath() : null);
/// <summary>
/// Same, with the profile path the caller already resolved for its guard. Callers that
/// guard the expanded path must use this overload: while detection is still retrying, a
/// second lookup can find the user the first one missed, and the guard would then judge a
/// C:\Users path without the profile that admits it.
/// </summary>
public static string ExpandCustomTokens(string rawPath, string userProfilePath)
{
if (string.IsNullOrEmpty(rawPath))
return rawPath;
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
{
var profilePath = GetLoggedOnUserProfilePath();
if (profilePath == null)
if (userProfilePath == null)
return null; // No user detected — caller should skip this path
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
rawPath = ReplaceCaseInsensitive(rawPath, Token, userProfilePath);
}
return Environment.ExpandEnvironmentVariables(rawPath);
@@ -80,26 +109,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
/// <summary>
/// Resets the cached state. Only used for testing.
/// Resets the cached state and the seams. Only used for testing.
/// </summary>
internal static void Reset()
{
lock (Lock)
{
_resolved = false;
_userProfilePath = null;
_lastFailedDetectionUtc = DateTime.MinValue;
_detector = DetectLoggedOnUserProfile;
_utcNow = () => DateTime.UtcNow;
}
}
/// <summary>
/// Allows tests to inject a specific profile path without WMI.
/// Allows tests to inject a specific profile path without WMI. Null pins "no user signed in"
/// — detection keeps answering null instead of finding the developer's own session.
/// </summary>
internal static void SetForTesting(string profilePath)
{
SetDetectorForTesting(() => profilePath, () => DateTime.UtcNow);
}
/// <summary>Replaces detection and clock, clearing what was cached. Only used for testing.</summary>
internal static void SetDetectorForTesting(Func<string> detector, Func<DateTime> utcNow)
{
lock (Lock)
{
_userProfilePath = profilePath;
_resolved = true;
_userProfilePath = null;
_lastFailedDetectionUtc = DateTime.MinValue;
_detector = detector ?? throw new ArgumentNullException(nameof(detector));
_utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow));
}
}
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
@@ -0,0 +1,313 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
/// <summary>
/// Text encoding of a log file as the logparser reads it. Chosen from the byte order mark at
/// the start of the file in <see cref="StreamReader"/>'s detection order; without one the file
/// is UTF-8, which is also how ANSI logs were always read (invalid bytes decode to U+FFFD).
/// </summary>
internal sealed class LogTextEncoding
{
public static readonly LogTextEncoding Utf8 = new LogTextEncoding("utf-8", new UTF8Encoding(false), 1, 0);
public static readonly LogTextEncoding Utf8WithBom = new LogTextEncoding("utf-8 with bom", new UTF8Encoding(false), 1, 3);
public static readonly LogTextEncoding Utf16LittleEndian = new LogTextEncoding("utf-16le", new UnicodeEncoding(false, false), 2, 2);
public static readonly LogTextEncoding Utf16BigEndian = new LogTextEncoding("utf-16be", new UnicodeEncoding(true, false), 2, 2);
public static readonly LogTextEncoding Utf32LittleEndian = new LogTextEncoding("utf-32le", new UTF32Encoding(false, false), 4, 4);
public static readonly LogTextEncoding Utf32BigEndian = new LogTextEncoding("utf-32be", new UTF32Encoding(true, false), 4, 4);
private LogTextEncoding(string name, Encoding encoding, int unitSize, int byteOrderMarkLength)
{
Name = name;
Encoding = encoding;
UnitSize = unitSize;
ByteOrderMarkLength = byteOrderMarkLength;
LineFeed = encoding.GetBytes("\n");
CarriageReturn = encoding.GetBytes("\r");
}
public string Name { get; }
public Encoding Encoding { get; }
/// <summary>Bytes per code unit; every line boundary lies on a unit boundary.</summary>
public int UnitSize { get; }
/// <summary>Length of the byte order mark at offset 0 — skipped, never part of a line.</summary>
public int ByteOrderMarkLength { get; }
public byte[] LineFeed { get; }
public byte[] CarriageReturn { get; }
public static LogTextEncoding Detect(byte[] head)
{
if (StartsWith(head, 0xEF, 0xBB, 0xBF)) return Utf8WithBom;
if (StartsWith(head, 0xFF, 0xFE, 0x00, 0x00)) return Utf32LittleEndian;
if (StartsWith(head, 0xFF, 0xFE)) return Utf16LittleEndian;
if (StartsWith(head, 0xFE, 0xFF)) return Utf16BigEndian;
if (StartsWith(head, 0x00, 0x00, 0xFE, 0xFF)) return Utf32BigEndian;
return Utf8;
}
/// <summary>The last code-unit boundary at or before <paramref name="position"/>.</summary>
public long AlignDown(long position)
{
if (position <= ByteOrderMarkLength) return position < ByteOrderMarkLength ? 0 : position;
return position - (position - ByteOrderMarkLength) % UnitSize;
}
private static bool StartsWith(byte[] bytes, params byte[] prefix)
{
if (bytes.Length < prefix.Length) return false;
for (var i = 0; i < prefix.Length; i++)
if (bytes[i] != prefix[i]) return false;
return true;
}
}
/// <summary>
/// The first bytes of a log file: they carry its byte order mark and identify the file, so a
/// file replaced under the same name is recognised even when it is already longer than the
/// position read so far.
/// </summary>
internal sealed class LogFileHead
{
internal const int MaxBytes = 4096;
private readonly byte[] _bytes;
private LogFileHead(byte[] bytes)
{
_bytes = bytes;
}
/// <summary>Up to <see cref="MaxBytes"/> from offset 0. Leaves the stream at an undefined position.</summary>
public static LogFileHead Read(Stream stream)
{
stream.Seek(0, SeekOrigin.Begin);
var buffer = new byte[(int)Math.Min(MaxBytes, stream.Length)];
var read = 0;
while (read < buffer.Length)
{
var n = stream.Read(buffer, read, buffer.Length - read);
if (n <= 0) break;
read += n;
}
if (read < buffer.Length) Array.Resize(ref buffer, read);
return new LogFileHead(buffer);
}
internal static LogFileHead FromBytes(byte[] bytes) => new LogFileHead(bytes);
public int Length => _bytes.Length;
public LogTextEncoding Encoding => LogTextEncoding.Detect(_bytes);
/// <summary>SHA-256 of the first <paramref name="length"/> bytes, Base64.</summary>
public string Hash(int length)
{
using (var sha = SHA256.Create())
return Convert.ToBase64String(sha.ComputeHash(_bytes, 0, length));
}
}
/// <summary>
/// Byte-exact line reader for the logparser collector. <see cref="Position"/> is the file
/// offset right behind the last line returned, so a caller that stops after any line can
/// resume exactly there — <see cref="StreamReader"/> only exposes its read-ahead boundary.
/// <para>
/// Line ends are those of <see cref="StreamReader.ReadLine"/>: "\r\n", "\n" and a lone "\r",
/// in code units of the file's <see cref="LogTextEncoding"/>. A "\n" directly after the start
/// position is skipped when the unit before it is "\r": the previous run ended on a "\r" whose
/// "\n" had not been written yet. A line at end of file without a line end is returned with
/// <see cref="LastLineTerminated"/> false; the caller decides whether the writer is still in
/// it. A line longer than the cap is consumed without being kept: <see cref="ReadLine"/>
/// returns an empty string and sets <see cref="LastLineTruncated"/>.
/// </para>
/// </summary>
internal sealed class LogLineReader
{
private const int ReadBufferSize = 64 * 1024;
private readonly Stream _stream;
private readonly LogTextEncoding _encoding;
private readonly int _unit;
private readonly int _maxLineBytes;
private readonly byte[] _buffer = new byte[ReadBufferSize];
private int _pos;
private int _len;
private long _bufferOffset;
private bool _eof;
private bool _skipLineFeedAfterCarriageReturn;
private byte[] _line = new byte[4096];
private int _lineLength;
/// <param name="stream">Seekable stream positioned at a code-unit boundary.</param>
public LogLineReader(Stream stream, LogTextEncoding encoding, int maxLineBytes)
{
if (stream == null) throw new ArgumentNullException(nameof(stream));
if (encoding == null) throw new ArgumentNullException(nameof(encoding));
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
_stream = stream;
_encoding = encoding;
_unit = encoding.UnitSize;
_maxLineBytes = maxLineBytes;
_bufferOffset = stream.Position;
_skipLineFeedAfterCarriageReturn = PrecededByCarriageReturn();
}
/// <summary>File offset of the next unread byte, exact.</summary>
public long Position => _bufferOffset + _pos;
/// <summary>File offset of the first byte of the line most recently returned.</summary>
public long LastLineStart { get; private set; }
/// <summary>False for a line that reached end of file without a line end.</summary>
public bool LastLineTerminated { get; private set; }
/// <summary>True when the last line was longer than the cap; it was consumed, not returned.</summary>
public bool LastLineTruncated { get; private set; }
/// <summary>The next line without its line end, or null at end of file.</summary>
public string ReadLine()
{
if (Position == 0 && _encoding.ByteOrderMarkLength > 0 && Ensure(_encoding.ByteOrderMarkLength))
_pos += _encoding.ByteOrderMarkLength;
if (_skipLineFeedAfterCarriageReturn)
{
_skipLineFeedAfterCarriageReturn = false;
if (Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
_pos += _unit;
}
LastLineStart = Position;
LastLineTerminated = false;
LastLineTruncated = false;
_lineLength = 0;
while (true)
{
if (!Ensure(_unit))
{
// End of file. A partial code unit (a writer mid-character) belongs to the line.
var rest = _len - _pos;
if (rest > 0)
{
Append(_pos, rest);
_pos = _len;
}
return Position > LastLineStart ? Decode() : null;
}
// Scan the whole code units in the buffer for the next line end and take the text
// before it in one copy.
var end = _pos + (_len - _pos) / _unit * _unit;
var scan = _pos;
while (scan < end && !IsLineEnd(scan))
scan += _unit;
if (scan > _pos)
{
Append(_pos, scan - _pos);
_pos = scan;
}
if (scan == end)
continue; // no line end in the buffer yet — refill
var carriageReturn = !UnitAt(_pos, _encoding.LineFeed);
_pos += _unit;
if (carriageReturn && Ensure(_unit) && UnitAt(_pos, _encoding.LineFeed))
_pos += _unit;
LastLineTerminated = true;
return Decode();
}
}
private bool IsLineEnd(int offset)
{
if (_unit == 1)
{
var b = _buffer[offset];
return b == (byte)'\n' || b == (byte)'\r';
}
return UnitAt(offset, _encoding.LineFeed) || UnitAt(offset, _encoding.CarriageReturn);
}
private bool PrecededByCarriageReturn()
{
var start = _bufferOffset;
if (start - _unit < _encoding.ByteOrderMarkLength) return false;
var previous = new byte[_unit];
_stream.Seek(start - _unit, SeekOrigin.Begin);
var read = 0;
while (read < _unit)
{
var n = _stream.Read(previous, read, _unit - read);
if (n <= 0) break;
read += n;
}
_stream.Seek(start, SeekOrigin.Begin);
if (read < _unit) return false;
for (var i = 0; i < _unit; i++)
if (previous[i] != _encoding.CarriageReturn[i]) return false;
return true;
}
/// <summary>At least <paramref name="count"/> unread bytes in the buffer, unless the file ends first.</summary>
private bool Ensure(int count)
{
while (_len - _pos < count)
{
if (_eof) return false;
var remaining = _len - _pos;
if (_pos > 0)
{
Buffer.BlockCopy(_buffer, _pos, _buffer, 0, remaining);
_bufferOffset += _pos;
_pos = 0;
_len = remaining;
}
var read = _stream.Read(_buffer, _len, _buffer.Length - _len);
if (read <= 0)
{
_eof = true;
return _len - _pos >= count;
}
_len += read;
}
return true;
}
private bool UnitAt(int offset, byte[] unit)
{
for (var i = 0; i < _unit; i++)
if (_buffer[offset + i] != unit[i]) return false;
return true;
}
private void Append(int offset, int count)
{
var room = _maxLineBytes - _lineLength;
if (count > room)
{
LastLineTruncated = true;
count = room;
if (count <= 0) return;
}
if (_lineLength + count > _line.Length)
{
var grown = new byte[Math.Max(_line.Length * 2, _lineLength + count)];
Buffer.BlockCopy(_line, 0, grown, 0, _lineLength);
_line = grown;
}
Buffer.BlockCopy(_buffer, offset, _line, _lineLength, count);
_lineLength += count;
}
private string Decode() =>
_lineLength == 0 || LastLineTruncated ? string.Empty : _encoding.Encoding.GetString(_line, 0, _lineLength);
}
}
@@ -18,6 +18,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
public string CollectorType => "logparser";
/// <summary>A wildcard target reads at most this many files per run, the newest first.</summary>
internal const int MaxFilesPerRun = 20;
/// <summary>Lines one run reads from one file unless the rule sets <c>maxLines</c>.</summary>
internal const int DefaultMaxLines = 1000;
/// <summary>The IME tracker's line bound: a longer physical line is skipped, never matched.</summary>
internal const int MaxLineBytes = 32 * 1024 * 1024;
/// <summary>
/// Executes the log parser rule. Returns null because logparser emits events directly
/// via context.OnEventCollected rather than returning a single result dictionary.
@@ -34,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
if (filePath == null)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
@@ -84,10 +93,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
bool.TryParse(trackPositionStr, out trackPosition);
// A value that is no positive number keeps the default: a run capped at 0 lines would
// never advance its position, and the rule would read nothing for the whole session.
string maxLinesStr;
int maxLines = 1000;
int maxLines = DefaultMaxLines;
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
int.TryParse(maxLinesStr, out maxLines);
{
int parsedMaxLines;
if (int.TryParse(maxLinesStr, out parsedMaxLines) && parsedMaxLines > 0)
maxLines = parsedMaxLines;
else
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"maxLines '{maxLinesStr}' is not a positive number — using {DefaultMaxLines}");
}
// Determine format: "cmtrace" (default) or "text" for plain text logs
string formatStr;
@@ -144,7 +162,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
/// as the event data). Stops after <see cref="MaxTracedMatchesPerFile"/> matches.
/// </summary>
private static void TraceMatch(GatherRuleContext context, GatherRule rule, string fileName,
int lineNumber, Match match, Regex pattern, int matchNumber)
long lineNumber, Match match, Regex pattern, int matchNumber)
{
if (matchNumber > MaxTracedMatchesPerFile)
{
@@ -195,14 +213,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
try
{
// Return matched files sorted by last write time (newest first), capped at 20
// Return matched files sorted by last write time (newest first), capped at MaxFilesPerRun
var allMatches = Directory.GetFiles(directory, fileNamePart);
if (allMatches.Length > 20)
if (allMatches.Length > MaxFilesPerRun)
context.DebugLog(ruleId, GatherRuleDebugLog.StageLogParser,
$"wildcard matched {allMatches.Length} files — capped to the 20 newest");
$"wildcard matched {allMatches.Length} files — capped to the {MaxFilesPerRun} newest");
return allMatches
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
.Take(20)
.Take(MaxFilesPerRun)
.ToList();
}
catch (Exception ex)
@@ -216,183 +234,248 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
{
var fileName = Path.GetFileName(filePath);
try
{
var fileInfo = new FileInfo(filePath);
long startPosition = trackPosition
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
: 0;
var state = context.SessionState;
var known = trackPosition ? state.PeekLogPosition(rule.RuleId, filePath) : null;
// Nothing new to read
if (startPosition >= fileInfo.Length)
// Nothing new since the last run — decided from the length alone, without opening the file.
var length = new FileInfo(filePath).Length;
if (known != null && known.Position == length)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: no new content (position {startPosition} >= length {fileInfo.Length}) — trackPosition=true, nothing re-read");
$"{fileName}: no new content (position {known.Position} >= length {length}) — trackPosition=true, nothing re-read" +
(known.FromPreviousRun ? "; position saved by a previous agent run" : ""));
return;
}
// A last line without a line end is usually one its writer is still in. An interval
// rule leaves it for its next run, once. A one-shot trigger may have no next run, so it
// reads the line now but saves the position before it: the next run reads it again,
// whole — at worst a match repeats, it is never split.
var isInterval = string.Equals(rule.Trigger, "interval", StringComparison.OrdinalIgnoreCase);
var mayHoldTail = trackPosition && isInterval;
var tailReadAgainNextRun = false;
int matchCount = 0;
int linesRead = 0;
int committedLines = 0;
int parseFailCount = 0;
int timeoutCount = 0;
long endPosition = startPosition;
int oversizedCount = 0;
long startPosition;
long endPosition;
long lineNumberBase;
long fileLength;
long heldTailStart = -1;
LogFileHead head;
LogTextEncoding encoding;
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
fileLength = stream.Length;
head = LogFileHead.Read(stream);
encoding = head.Encoding;
LogReadStart start;
startPosition = encoding.AlignDown(GatherRuleSessionState.ResolveStart(known, fileLength, head, out start));
lineNumberBase = start == LogReadStart.Continue ? known.LineNumber : 0;
TraceReadStart(context, rule, fileName, known, start, fileLength);
stream.Seek(startPosition, SeekOrigin.Begin);
var reader = new LogLineReader(stream, encoding, MaxLineBytes);
endPosition = startPosition;
using (var reader = new StreamReader(stream, Encoding.UTF8))
while (linesRead < maxLines)
{
string line;
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
var line = reader.ReadLine();
if (line == null)
{
linesRead++;
if (isTextMode)
{
// Text mode: match regex directly against the raw line
Match match;
try
{
match = pattern.Match(line);
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logLine"] = TruncateMessage(line, 500);
data["logLineNumber"] = linesRead;
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
GatherRuleExecutor.StampRuleMarkers(rule, data);
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
{
parseFailCount++;
continue;
}
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
// Gather-rule logs are arbitrary files this collector does not
// tail, so no writer offset can be measured for them; the
// reader-zone fallback carries its known defect (see
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
// the parser split.
var entryUtc = entry.TimestampUtc
?? (entry.HasTimestamp
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
: DateTime.UtcNow);
data["logTimestamp"] = entryUtc.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["logFile"] = Path.GetFileName(filePath);
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
GatherRuleExecutor.StampRuleMarkers(rule, data);
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = entryUtc,
EventType = eventType,
Severity = severity,
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
TraceMatch(context, rule, Path.GetFileName(filePath), linesRead, match, pattern, matchCount);
}
// End of file. A "\n" the reader skipped behind the "\r" the previous run
// ended on is consumed too — unless a kept tail must be read again.
if (!tailReadAgainNextRun)
endPosition = reader.Position;
break;
}
// Capture the final stream position after reading
endPosition = stream.Position;
var unfinished = !reader.LastLineTerminated;
if (unfinished && mayHoldTail
&& (known == null || known.HeldTailStart != reader.LastLineStart))
{
heldTailStart = reader.LastLineStart;
break;
}
linesRead++;
var lineNumber = lineNumberBase + linesRead;
if (unfinished && trackPosition && !isInterval)
{
tailReadAgainNextRun = true;
}
else
{
endPosition = reader.Position;
committedLines = linesRead;
}
if (reader.LastLineTruncated)
{
oversizedCount++;
continue;
}
if (isTextMode)
{
// Text mode: match regex directly against the raw line
Match match;
try
{
match = pattern.Match(line);
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
data["logLine"] = TruncateMessage(line, 500);
data["logLineNumber"] = lineNumber;
data["logFile"] = fileName;
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
GatherRuleExecutor.StampRuleMarkers(rule, data);
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = DateTime.UtcNow,
EventType = eventType,
Severity = severity,
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
}
else
{
// CMTrace mode: parse line as CMTrace format, match regex against message
CmTraceLogEntry entry;
if (!CmTraceLogParser.TryParseLine(line, out entry))
{
parseFailCount++;
continue;
}
Match match;
try
{
match = pattern.Match(entry.Message);
}
catch (RegexMatchTimeoutException)
{
timeoutCount++;
continue;
}
if (!match.Success)
continue;
var data = new Dictionary<string, object>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
data[groupName] = group.Value;
}
// Gather-rule logs are arbitrary files this collector does not
// tail, so no writer offset can be measured for them; the
// reader-zone fallback carries its known defect (see
// ResolveUtcAssumingReaderZone). Behaviour unchanged from before
// the parser split.
var entryUtc = entry.TimestampUtc
?? (entry.HasTimestamp
? CmTraceLogParser.ResolveUtcAssumingReaderZone(entry.LocalTimestamp)
: DateTime.UtcNow);
data["logTimestamp"] = entryUtc.ToString("o");
data["logComponent"] = entry.Component;
data["logType"] = entry.Type;
data["logMessage"] = TruncateMessage(entry.Message, 500);
data["logFile"] = fileName;
data["ruleId"] = rule.RuleId;
data["ruleTitle"] = rule.Title;
GatherRuleExecutor.StampRuleMarkers(rule, data);
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
? rule.OutputEventType
: "logparser_match";
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
context.OnEventCollected(new EnrollmentEvent
{
SessionId = context.SessionId,
TenantId = context.TenantId,
Timestamp = entryUtc,
EventType = eventType,
Severity = severity,
Source = GatherRuleExecutor.SourceName,
Message = $"Gather: {rule.Title}",
Data = data
});
matchCount++;
TraceMatch(context, rule, fileName, lineNumber, match, pattern, matchCount);
}
}
}
// Saved only now, after every event of this file went out: a process that dies
// before this line repeats the file's new lines after the restart, never skips them.
if (trackPosition)
context.FilePositionTracker.SetPosition(filePath, endPosition);
state.CommitLogPosition(rule.RuleId, filePath, endPosition, lineNumberBase + committedLines, head, heldTailStart);
if (matchCount > 0)
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {fileName}");
// Per-file outcome — always written so a "matched 0" run is visible too.
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{Path.GetFileName(filePath)}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}");
$"{fileName}: read {linesRead} lines from position {startPosition}->{endPosition}, " +
$"matched {matchCount}, parseFailures={parseFailCount}, regexTimeouts={timeoutCount}, mode={(isTextMode ? "text" : "cmtrace")}, encoding={encoding.Name}" +
(oversizedCount > 0 ? $", skipped {oversizedCount} line(s) over {MaxLineBytes / (1024 * 1024)} MB" : ""));
if (heldTailStart >= 0)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: last line has no line end yet — left for the next run, read then even if still unfinished");
else if (tailReadAgainNextRun)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: last line has no line end yet — read now and again by the next run, position kept before it");
if (!isTextMode && linesRead > 0 && parseFailCount == linesRead)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
"every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
if (linesRead == maxLines)
if (linesRead == maxLines && endPosition < fileLength)
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"stopped at maxLines={maxLines} — remaining content is deferred to the next run");
}
@@ -403,6 +486,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
}
}
private static void TraceReadStart(GatherRuleContext context, GatherRule rule, string fileName,
LogPositionSnapshot known, LogReadStart start, long fileLength)
{
if (start == LogReadStart.Shrunk)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: file is shorter than the saved position (length {fileLength} < {known.Position}) — truncated or recreated, reading from the beginning");
}
else if (start == LogReadStart.Replaced)
{
context.Logger.Info($"LogParser rule {rule.RuleId}: {fileName} was replaced since the last read — reading it from the beginning");
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: file start changed since the last read (replaced or rewritten) — saved position {known.Position} discarded, reading from the beginning");
}
else if (start == LogReadStart.Continue && known.FromPreviousRun)
{
context.DebugLog(rule.RuleId, GatherRuleDebugLog.StageLogParser,
$"{fileName}: continuing at position {known.Position} (after line {known.LineNumber}) saved by a previous agent run");
}
}
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
{
// If the rule specifies a severity, use it
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
filePath = UserProfileResolver.ExpandCustomTokens(filePath, userProfilePath);
if (filePath == null)
return data; // Token present but no user logged on — skip silently
@@ -4,7 +4,6 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
@@ -19,7 +18,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public string TenantId { get; }
public Action<EnrollmentEvent> OnEventCollected { get; }
public string ImeLogPathOverride { get; }
public LogFilePositionTracker FilePositionTracker { get; }
/// <summary>Logparser positions per (rule, file) and on_change values; survives agent restarts when persisted.</summary>
public GatherRuleSessionState SessionState { get; }
public bool UnrestrictedMode { get; set; }
/// <summary>
@@ -34,7 +36,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
string tenantId,
Action<EnrollmentEvent> onEventCollected,
string imeLogPathOverride,
LogFilePositionTracker filePositionTracker,
GatherRuleSessionState sessionState,
GatherRuleDebugLog debugLog = null)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -42,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
ImeLogPathOverride = imeLogPathOverride;
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
SessionState = sessionState ?? throw new ArgumentNullException(nameof(sessionState));
DebugWriter = debugLog;
}
@@ -10,7 +10,6 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
@@ -38,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
private readonly AgentLogger _logger;
private readonly GatherRuleDebugLog _debug; // null unless EnableGatherRuleDebugLog / --gather-debug-log
private readonly GatherRuleSessionState _state;
private readonly GatherRuleContext _context;
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
@@ -50,12 +50,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule).
// Guarded by _scopeLock; collector work always runs outside the lock. The state deliberately
// survives UpdateRules config refreshes — an agent restart causes at most one re-emit per
// on_change rule (acceptable under the per-enrollment lifecycle).
// survives UpdateRules config refreshes. Across agent restarts only the last emitted hash
// survives: it is seeded from _state and committed there after each emit, so an unchanged
// on_change result stays silent after a reboot. Phase latches, dedup sets and the
// suppression streak are process-scoped.
private readonly object _scopeLock = new object();
private EnrollmentPhase _currentPhase = EnrollmentPhase.Unknown;
private readonly HashSet<string> _fromPhaseLatched = new HashSet<string>();
private readonly Dictionary<string, string> _lastEmittedHash = new Dictionary<string, string>();
private readonly HashSet<string> _hashFromPreviousRun = new HashSet<string>();
private readonly Dictionary<string, (int Count, DateTime SinceUtc)> _suppressed =
new Dictionary<string, (int Count, DateTime SinceUtc)>();
private readonly HashSet<string> _scopeBypassLogged = new HashSet<string>();
@@ -77,9 +80,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
set { _context.UnrestrictedMode = value; }
}
/// <param name="stateDirectory">
/// Where logparser positions and on_change values are kept across agent restarts. Null keeps
/// them in memory — required for <c>--run-gather-rules</c>, which runs beside the live agent
/// under a throwaway session id and must not overwrite the session's file.
/// </param>
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
AgentLogger logger, string imeLogPathOverride = null, string debugLogPath = null,
Action<string> debugEcho = null)
Action<string> debugEcho = null, string stateDirectory = null)
{
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
@@ -89,8 +97,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
_logger = logger;
_debug = string.IsNullOrEmpty(debugLogPath) ? null : new GatherRuleDebugLog(debugLogPath, logger, debugEcho);
var filePositionTracker = new LogFilePositionTracker();
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker, _debug);
var persistence = string.IsNullOrEmpty(stateDirectory) ? null : new GatherRuleStatePersistence(stateDirectory, logger);
_state = new GatherRuleSessionState(sessionId, persistence, logger);
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, _state, _debug);
foreach (var hash in _state.GetEmittedHashes())
{
_lastEmittedHash[hash.Key] = hash.Value;
_hashFromPreviousRun.Add(hash.Key);
}
_logger.Info($"GatherRuleExecutor: gather state {_state.RestoreSummary}");
DebugLog(null, GatherRuleDebugLog.StageConfig, $"gather state: {_state.RestoreSummary}");
foreach (var detail in _state.RestoredDetails)
DebugLog(detail.Key, GatherRuleDebugLog.StageConfig, detail.Value);
// Register all collector strategies
var collectorList = new IGatherRuleCollector[]
@@ -480,14 +499,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// empty result never reaches this point, so an emitOnlyIfExists miss neither
// emits nor updates the hash — composing to "one event on appearance, then
// only on change".
string emittedHash = null;
if (IsOnChangeMode(rule))
{
string hashPrefix;
int suppressedStreak;
if (!ShouldEmitOnChange(rule, result, out hashPrefix, out suppressedStreak))
bool comparedWithPreviousRun;
if (!ShouldEmitOnChange(rule, result, out emittedHash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun))
{
DebugLog(rule.RuleId, GatherRuleDebugLog.StageSuppress,
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}");
$"result unchanged (hash {hashPrefix}) — emit suppressed (on_change), suppressed streak={suppressedStreak}" +
(comparedWithPreviousRun ? "; compared with the value a previous agent run emitted" : ""));
return;
}
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
@@ -524,6 +546,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
Message = $"Gather: {rule.Title}",
Data = result
});
// Committed only after the event went out: a process that dies in between emits
// the result once more after the restart instead of never.
if (emittedHash != null)
_state.CommitEmittedHash(rule.RuleId, emittedHash);
DebugLog(rule.RuleId, GatherRuleDebugLog.StageEmit,
$"emitted {eventType} (severity={severity}, {result.Count} fields)");
}
@@ -679,9 +707,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
internal bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
out string hashPrefix, out int suppressedStreak)
{
var hash = ComputeCanonicalHash(result);
string hash;
bool comparedWithPreviousRun;
return ShouldEmitOnChange(rule, result, out hash, out hashPrefix, out suppressedStreak, out comparedWithPreviousRun);
}
/// <summary>
/// Full form: <paramref name="hash"/> is the result's canonical hash, committed to the session
/// state once the event went out; <paramref name="comparedWithPreviousRun"/> says a suppression
/// compared against the value a previous agent run emitted.
/// </summary>
private bool ShouldEmitOnChange(GatherRule rule, Dictionary<string, object> result,
out string hash, out string hashPrefix, out int suppressedStreak, out bool comparedWithPreviousRun)
{
hash = ComputeCanonicalHash(result);
hashPrefix = hash.Length > 8 ? hash.Substring(0, 8) : hash;
suppressedStreak = 0;
comparedWithPreviousRun = false;
bool emit;
lock (_scopeLock)
{
@@ -693,11 +735,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
? (streak.Count + 1, streak.SinceUtc)
: (1, DateTime.UtcNow);
suppressedStreak = _suppressed[rule.RuleId].Count;
comparedWithPreviousRun = _hashFromPreviousRun.Contains(rule.RuleId);
emit = false;
}
else
{
_lastEmittedHash[rule.RuleId] = hash;
_hashFromPreviousRun.Remove(rule.RuleId);
(int Count, DateTime SinceUtc) streak;
if (_suppressed.TryGetValue(rule.RuleId, out streak) && streak.Count > 0)
{
@@ -820,6 +864,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
StopAllTimers();
_startupRulesLatch?.Dispose();
_startupRulesLatch = null;
// A rule still finishing on a pool thread keeps its in-memory state but no longer writes:
// the agent may be removing its own folder right now.
_state.Close();
}
}
}
@@ -0,0 +1,399 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Gather-rule state of one enrollment session that survives agent restarts: where each
/// logparser rule stopped reading each file, and the on_change value each rule last emitted.
/// <para>
/// Positions are kept per (rule, file) — two rules parsing the same log each read all of it.
/// Every position carries a fingerprint of the file's first bytes, so a file replaced under
/// the same name is read from the beginning even when it is already longer than the position.
/// </para>
/// <para>
/// Every commit is written to disk synchronously, AFTER the events it covers were handed on:
/// a process that dies in between repeats that one pass after the restart instead of losing it.
/// The lock guards memory and file write together and is never taken under the executor's
/// scope lock. Without a persistence (diagnostic mode, tests) the state lives in memory only.
/// </para>
/// </summary>
public sealed class GatherRuleSessionState
{
/// <summary>
/// Files remembered per rule. A run reads at most <see cref="LogParserCollector.MaxFilesPerRun"/>
/// files; the least recently touched entry beyond the cap is dropped, and its file — should it
/// come back — is simply read again.
/// </summary>
internal const int MaxLogFilesPerRule = 5 * LogParserCollector.MaxFilesPerRun;
private static readonly char[] WildcardChars = { '*', '?' };
private readonly object _gate = new object();
private readonly string _sessionId;
private readonly GatherRuleStatePersistence _persistence;
private readonly AgentLogger _logger;
private readonly Dictionary<string, Dictionary<string, Entry>> _logPositions =
new Dictionary<string, Dictionary<string, Entry>>(StringComparer.Ordinal);
private readonly Dictionary<string, string> _emittedHashes = new Dictionary<string, string>(StringComparer.Ordinal);
private readonly List<KeyValuePair<string, string>> _restoredDetails = new List<KeyValuePair<string, string>>();
private long _touchCounter;
private bool _unsaved;
private bool _saveFailureLogged;
private bool _closed;
private sealed class Entry
{
public long Position;
public long LineNumber;
public int HeadLength;
public string HeadHash;
public bool FromPreviousRun;
public long HeldTailStart = -1;
public long LastTouched;
}
public GatherRuleSessionState(string sessionId, GatherRuleStatePersistence persistence, AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_persistence = persistence;
RestoreSummary = Restore();
}
/// <summary>One line on what the constructor found on disk — for the agent log and the debug trace.</summary>
public string RestoreSummary { get; }
/// <summary>(ruleId, message) per restored position and on_change value — for the debug trace.</summary>
internal IReadOnlyList<KeyValuePair<string, string>> RestoredDetails => _restoredDetails;
// ===== logparser positions =====
/// <summary>The recorded position of <paramref name="filePath"/> for the rule, or null when it has none.</summary>
internal LogPositionSnapshot PeekLogPosition(string ruleId, string filePath)
{
lock (_gate)
{
Dictionary<string, Entry> files;
Entry entry;
if (!_logPositions.TryGetValue(ruleId, out files) || !files.TryGetValue(filePath, out entry))
return null;
entry.LastTouched = ++_touchCounter;
return new LogPositionSnapshot(entry.Position, entry.LineNumber, entry.HeadLength, entry.HeadHash,
entry.FromPreviousRun, entry.HeldTailStart);
}
}
/// <summary>
/// Records where the rule stopped in the file and saves the state when it changed.
/// <paramref name="heldTailStart"/> (in memory only) marks an unfinished last line left for
/// the next run; -1 when none.
/// </summary>
internal void CommitLogPosition(string ruleId, string filePath, long position, long lineNumber,
LogFileHead head, long heldTailStart)
{
var headLength = head.Length;
var headHash = headLength > 0 ? head.Hash(headLength) : null;
lock (_gate)
{
Dictionary<string, Entry> files;
if (!_logPositions.TryGetValue(ruleId, out files))
{
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
_logPositions[ruleId] = files;
}
Entry entry;
var changed = false;
if (!files.TryGetValue(filePath, out entry))
{
entry = new Entry();
files[filePath] = entry;
changed = true;
}
if (changed || entry.Position != position || entry.LineNumber != lineNumber
|| entry.HeadLength != headLength || !string.Equals(entry.HeadHash, headHash, StringComparison.Ordinal))
{
entry.Position = position;
entry.LineNumber = lineNumber;
entry.HeadLength = headLength;
entry.HeadHash = headHash;
entry.FromPreviousRun = false;
changed = true;
}
entry.HeldTailStart = heldTailStart;
entry.LastTouched = ++_touchCounter;
if (files.Count > MaxLogFilesPerRule)
EvictLeastRecentlyTouched(ruleId, files);
if (changed || _unsaved)
SaveLocked();
}
}
/// <summary>
/// Where to continue reading: the recorded position while the file is still the one it was
/// recorded for, otherwise 0 with the reason.
/// </summary>
internal static long ResolveStart(LogPositionSnapshot known, long fileLength, LogFileHead head, out LogReadStart start)
{
if (known == null)
{
start = LogReadStart.FirstRead;
return 0;
}
if (fileLength < known.Position)
{
start = LogReadStart.Shrunk;
return 0;
}
if (known.HeadLength > 0
&& (head.Length < known.HeadLength
|| !string.Equals(head.Hash(known.HeadLength), known.HeadHash, StringComparison.Ordinal)))
{
start = LogReadStart.Replaced;
return 0;
}
start = LogReadStart.Continue;
return known.Position;
}
// ===== on_change values =====
/// <summary>Rule id → hash of the result the rule last emitted.</summary>
internal Dictionary<string, string> GetEmittedHashes()
{
lock (_gate)
return new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal);
}
/// <summary>Records the hash of a result that was just emitted and saves the state when it changed.</summary>
internal void CommitEmittedHash(string ruleId, string hash)
{
lock (_gate)
{
string current;
var changed = !_emittedHashes.TryGetValue(ruleId, out current)
|| !string.Equals(current, hash, StringComparison.Ordinal);
_emittedHashes[ruleId] = hash;
if (changed || _unsaved)
SaveLocked();
}
}
/// <summary>Retries a save that failed earlier, then stops writing (executor disposed).</summary>
internal void Close()
{
lock (_gate)
{
if (_unsaved)
SaveLocked();
_closed = true;
}
}
// ===== persistence =====
private void SaveLocked()
{
if (_persistence == null || _closed)
{
_unsaved = false;
return;
}
var data = new GatherRuleStateData
{
SessionId = _sessionId,
SavedAtUtc = DateTime.UtcNow,
LogPositions = _logPositions
.SelectMany(rule => rule.Value.Select(file => new { RuleId = rule.Key, Path = file.Key, Entry = file.Value }))
.OrderBy(x => x.Entry.LastTouched)
.Select(x => new GatherLogPositionData
{
RuleId = x.RuleId,
Path = x.Path,
Position = x.Entry.Position,
LineNumber = x.Entry.LineNumber,
HeadLength = x.Entry.HeadLength,
HeadHash = x.Entry.HeadHash,
})
.ToList(),
OnChangeHashes = new Dictionary<string, string>(_emittedHashes, StringComparer.Ordinal),
};
if (_persistence.Save(data))
{
_unsaved = false;
_saveFailureLogged = false;
return;
}
_unsaved = true;
if (!_saveFailureLogged)
{
_saveFailureLogged = true;
_logger.Warning($"GatherRuleSessionState: saving {_persistence.StateFilePath} failed — kept in memory, retried at the next change");
}
}
private string Restore()
{
if (_persistence == null)
return "persistence off (no state directory) — positions and on_change values live in memory only";
string discardReason;
var data = _persistence.Load(_sessionId, out discardReason);
if (data == null)
{
return discardReason == null
? "no saved state — first agent run of this session"
: $"saved state discarded ({discardReason}) — starting fresh";
}
// The file is input like any other: whatever it holds, the executor must come up. A
// failure here would otherwise stop every collector host, at every restart of the session.
try
{
return RestoreFrom(data);
}
catch (Exception ex)
{
_logPositions.Clear();
_emittedHashes.Clear();
_restoredDetails.Clear();
_logger.Warning($"GatherRuleSessionState: restoring {_persistence.StateFilePath} failed, starting fresh: {ex.Message}");
return $"saved state discarded (restore failed: {ex.Message}) — starting fresh";
}
}
private string RestoreFrom(GatherRuleStateData data)
{
var dropped = 0;
foreach (var position in data.LogPositions ?? new List<GatherLogPositionData>())
{
if (!IsValid(position))
{
dropped++;
continue;
}
Dictionary<string, Entry> files;
if (!_logPositions.TryGetValue(position.RuleId, out files))
{
files = new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
_logPositions[position.RuleId] = files;
}
files[position.Path] = new Entry
{
Position = position.Position,
LineNumber = position.LineNumber,
HeadLength = position.HeadLength,
HeadHash = position.HeadLength > 0 ? position.HeadHash : null,
FromPreviousRun = true,
LastTouched = ++_touchCounter,
};
if (files.Count > MaxLogFilesPerRule)
EvictLeastRecentlyTouched(position.RuleId, files);
}
foreach (var hash in data.OnChangeHashes ?? new Dictionary<string, string>())
{
if (string.IsNullOrEmpty(hash.Key) || string.IsNullOrEmpty(hash.Value))
{
dropped++;
continue;
}
_emittedHashes[hash.Key] = hash.Value;
}
foreach (var rule in _logPositions)
{
foreach (var file in rule.Value)
{
_restoredDetails.Add(new KeyValuePair<string, string>(rule.Key,
$"logparser position restored: {Path.GetFileName(file.Key)} at byte {file.Value.Position}, after line {file.Value.LineNumber}"));
}
}
foreach (var hash in _emittedHashes)
{
_restoredDetails.Add(new KeyValuePair<string, string>(hash.Key,
$"on_change value restored (hash {Prefix(hash.Value)}) — an unchanged result stays silent"));
}
var positionCount = _logPositions.Sum(rule => rule.Value.Count);
return $"restored {positionCount} logparser position(s) for {_logPositions.Count} rule(s) and {_emittedHashes.Count} on_change value(s), " +
$"saved {data.SavedAtUtc:yyyy-MM-ddTHH:mm:ssZ} by a previous agent run of this session" +
(dropped > 0 ? $"; {dropped} invalid entr{(dropped == 1 ? "y" : "ies")} dropped" : "") +
" — logparser rules continue after their saved position, on_change rules stay silent until their result changes";
}
private static bool IsValid(GatherLogPositionData position)
{
if (position == null || string.IsNullOrEmpty(position.RuleId) || string.IsNullOrEmpty(position.Path))
return false;
// A real resolved path never carries these; Path APIs throw on them under .NET Framework.
if (position.Path.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || position.Path.IndexOfAny(WildcardChars) >= 0)
return false;
if (position.Position < 0 || position.LineNumber < 0)
return false;
if (position.HeadLength < 0 || position.HeadLength > LogFileHead.MaxBytes)
return false;
if (position.HeadLength > 0 && string.IsNullOrEmpty(position.HeadHash))
return false;
// Reading any byte records a non-empty head; a position without one cannot be verified.
return position.Position == 0 || position.HeadLength > 0;
}
private void EvictLeastRecentlyTouched(string ruleId, Dictionary<string, Entry> files)
{
var oldest = files.OrderBy(file => file.Value.LastTouched).First();
files.Remove(oldest.Key);
_logger.Info($"GatherRuleSessionState: rule {ruleId} tracks more than {MaxLogFilesPerRule} files — forgot the position of {Path.GetFileName(oldest.Key)}");
}
private static string Prefix(string hash) => hash.Length > 8 ? hash.Substring(0, 8) : hash;
}
/// <summary>Immutable copy of a recorded logparser position.</summary>
internal sealed class LogPositionSnapshot
{
public LogPositionSnapshot(long position, long lineNumber, int headLength, string headHash,
bool fromPreviousRun, long heldTailStart)
{
Position = position;
LineNumber = lineNumber;
HeadLength = headLength;
HeadHash = headHash;
FromPreviousRun = fromPreviousRun;
HeldTailStart = heldTailStart;
}
public long Position { get; }
public long LineNumber { get; }
public int HeadLength { get; }
public string HeadHash { get; }
/// <summary>Restored from disk and not advanced by this process yet.</summary>
public bool FromPreviousRun { get; }
/// <summary>Start of an unfinished last line left for this run by the previous one; -1 when none.</summary>
public long HeldTailStart { get; }
}
/// <summary>How <see cref="GatherRuleSessionState.ResolveStart"/> decided where reading starts.</summary>
internal enum LogReadStart
{
FirstRead,
Continue,
Shrunk,
Replaced,
}
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
{
/// <summary>
/// Persists <see cref="GatherRuleSessionState"/> in the agent's state directory so one
/// enrollment's logparser positions and on_change values survive agent restarts, reboots and
/// the WhiteGlove Part 2 resume.
/// <para>
/// The state is only resumed for the SAME session id: the state directory is one fixed folder
/// that a session rotation does not clear, and the new session starts empty in the backend, so
/// it must read its logs from the beginning.
/// </para>
/// Fail-soft like <c>BandwidthStatePersistence</c>: I/O errors never throw; a missing, corrupt
/// or foreign file loads as null (fresh start — the pre-persistence behavior).
/// </summary>
public class GatherRuleStatePersistence
{
public const string FileName = "gather-rule-state.json";
private const string SourceName = "GatherRuleStatePersistence";
private readonly string _stateDirectory;
private readonly string _stateFilePath;
private readonly AgentLogger _logger;
public GatherRuleStatePersistence(string stateDirectory, AgentLogger logger)
{
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
_stateFilePath = Path.Combine(_stateDirectory, FileName);
_logger = logger;
}
public string StateFilePath => _stateFilePath;
/// <summary>
/// Loads the persisted state, or null when absent, unreadable, or written for a different
/// session. <paramref name="discardReason"/> is null when there simply was no file.
/// </summary>
public GatherRuleStateData Load(string expectedSessionId, out string discardReason)
{
discardReason = null;
if (!File.Exists(_stateFilePath)) return null;
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<GatherRuleStateData>(json);
if (state == null || string.IsNullOrEmpty(state.SessionId))
{
discardReason = "state file empty or invalid";
_logger.Warning($"[{SourceName}] persisted state file was empty or invalid — starting fresh");
return null;
}
if (!string.Equals(state.SessionId, expectedSessionId, StringComparison.OrdinalIgnoreCase))
{
discardReason = $"saved for session {state.SessionId}, current session is {expectedSessionId}";
_logger.Warning($"[{SourceName}] persisted state belongs to session {state.SessionId}, not {expectedSessionId} — starting fresh");
return null;
}
return state;
}
catch (Exception ex)
{
discardReason = $"state file unreadable: {ex.Message}";
_logger.Warning($"[{SourceName}] failed to load persisted state, starting fresh: {ex.Message}");
return null;
}
}
/// <summary>Persists the state (atomic write via temp file). Never throws; false when the write failed.</summary>
public bool Save(GatherRuleStateData state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var tempPath = _stateFilePath + ".tmp";
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state));
if (File.Exists(_stateFilePath))
{
File.Replace(tempPath, _stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true);
}
else
{
File.Move(tempPath, _stateFilePath);
}
return true;
}
catch (Exception ex)
{
_logger.Debug($"[{SourceName}] failed to save state: {ex.Message}");
return false;
}
}
}
// ---- DTOs for state serialization ----
public class GatherRuleStateData
{
public string SessionId { get; set; }
public DateTime SavedAtUtc { get; set; }
/// <summary>Least recently used first, so a restore keeps the eviction order.</summary>
public List<GatherLogPositionData> LogPositions { get; set; }
/// <summary>Rule id → canonical hash of the result the rule last emitted (on_change).</summary>
public Dictionary<string, string> OnChangeHashes { get; set; }
}
public class GatherLogPositionData
{
public string RuleId { get; set; }
public string Path { get; set; }
/// <summary>File offset right behind the last line the rule processed.</summary>
public long Position { get; set; }
/// <summary>Lines before <see cref="Position"/> — the base for <c>logLineNumber</c>.</summary>
public long LineNumber { get; set; }
/// <summary>Bytes from the start of the file the fingerprint covers (0..4096).</summary>
public int HeadLength { get; set; }
/// <summary>SHA-256 of those bytes, Base64; null when <see cref="HeadLength"/> is 0.</summary>
public string HeadHash { get; set; }
}
}
@@ -237,6 +237,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
using (response)
{
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -338,6 +339,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
errorCode: errorCode);
}
/// <summary>
/// Throws <see cref="BackendRefusalException"/> for a 409 whose JSON body carries a
/// machine-readable <c>errorCode</c> — register-session answers
/// <c>session_owner_mismatch</c> when another tenant claimed the session id, which no retry
/// can clear but a session rotation can. A 409 without a code falls through to
/// <c>EnsureSuccessStatusCode</c> as before.
/// </summary>
private static async Task ThrowOnCodedConflictAsync(HttpResponseMessage response)
{
if (response.StatusCode != System.Net.HttpStatusCode.Conflict)
return;
string body = null;
try
{
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch
{
// No body, no code — the generic 409 handling applies.
}
var errorCode = TryExtractErrorCode(body);
if (errorCode == null)
return;
throw new BackendRefusalException(
$"Backend returned 409 Conflict with error code {errorCode}.",
(int)response.StatusCode,
errorCode);
}
/// <summary>
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
/// or a non-string value yields null, never an exception.
@@ -471,4 +504,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
ErrorCode = errorCode;
}
}
/// <summary>
/// Thrown when the backend refuses a request on a non-auth status (409) with a machine-readable
/// code from Shared <c>Constants.AgentErrorCodes</c>. Unlike <see cref="BackendAuthException"/>
/// it says nothing about the device's authorization and must never feed the auth-failure tracker.
/// </summary>
public class BackendRefusalException : Exception
{
/// <summary>HTTP status code returned by the backend (409).</summary>
public int StatusCode { get; }
/// <summary>Machine-readable code from the backend's JSON body (never null).</summary>
public string ErrorCode { get; }
public BackendRefusalException(string message, int statusCode, string errorCode) : base(message)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
}
}
@@ -79,6 +79,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// exist for callers whose process is about to exit and cannot come back later
/// (emergency break): retries stay INSIDE the one reservation, so they never
/// multiply against the per-session budget.
///
/// <paramref name="sessionAgeHours"/> is the emergency break's measured session age
/// (<see cref="AgentErrorReport.SessionAgeHours"/>) and <paramref name="priorRegistrationFailure"/>
/// its record of earlier starts that never registered
/// (<see cref="AgentErrorReport.PriorRegistrationFailure"/>); null for every other error type.
/// </summary>
public virtual async Task TrySendAsync(
AgentErrorType errorType,
@@ -87,7 +92,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
long? sequenceNumber = null,
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null)
TimeSpan? retryDelay = null,
double? sessionAgeHours = null,
RegistrationFailureSummary priorRegistrationFailure = null)
{
// Deduplicate by error type + status code: same failure category sent only once per session.
// All anti-flood checks are protected by a lock to prevent race conditions when
@@ -135,6 +142,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
Message = message,
HttpStatusCode = httpStatusCode,
SequenceNumber = sequenceNumber,
SessionAgeHours = sessionAgeHours,
PriorRegistrationFailure = priorRegistrationFailure,
AgentVersion = _agentVersion,
Timestamp = DateTime.UtcNow
};
@@ -479,6 +479,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (collectors.StallProbeEnabled)
{
// A pending tenant Hello wait (up to 60 min) is a bounded wait that resolves the
// session either way — the probe holds session_stalled back until it is over.
Func<bool>? helloWaitPending = null;
if (decisionStateProbe != null)
helloWaitPending = () => DecisionEngine.IsTenantHelloWaitPending(decisionStateProbe(), clock.UtcNow);
hosts.Add(new StallProbeHost(
sessionId: sessionId,
tenantId: tenantId,
@@ -490,7 +496,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
sources: collectors.StallProbeSources,
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
isDevicePreparation: isDevicePreparation));
isDevicePreparation: isDevicePreparation,
helloWaitPending: helloWaitPending));
}
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
@@ -729,7 +736,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
unrestrictedMode: _agentConfig.UnrestrictedMode,
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
timelineEvents: timelineEvents));
timelineEvents: timelineEvents,
stateDirectory: _stateDirectory));
}
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
{
@@ -23,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
// with the RAW collector payload — before the reducer has applied gates like the
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
// RealmJoin completion gate. Consequence (session 32312a32): a phase_change
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
@@ -47,7 +47,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string? imeLogPathOverride,
bool unrestrictedMode = false,
string? gatherDebugLogPath = null,
TimelineEventStream? timelineEvents = null)
TimelineEventStream? timelineEvents = null,
string? stateDirectory = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -66,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var post = new InformationalEventPost(ingress, clock, logger);
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
debugLogPath: gatherDebugLogPath);
debugLogPath: gatherDebugLogPath, stateDirectory: stateDirectory);
_timelineEvents = timelineEvents;
}
@@ -58,7 +58,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string[]? sources,
int sessionStalledAfterProbeIndex,
int[]? harmlessModernDeploymentEventIds,
bool isDevicePreparation = false)
bool isDevicePreparation = false,
Func<bool>? helloWaitPending = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -74,7 +75,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
isDevicePreparation: isDevicePreparation);
isDevicePreparation: isDevicePreparation,
helloWaitPending: helloWaitPending);
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
_observableIngress = ingress as SignalIngress;
@@ -420,7 +420,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// Re-stamp AgentBootUtc so the current run's deadlines floor at "now" — the
// persisted boot anchor is from the prior run and using it would let replayed
// tail signals arm deadlines that are already past-due (premature fire).
seed = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc).Build();
// Re-stamp SessionId too: after a session rotation at registration
// (SESSION-OWNER-BINDING) the snapshot still names the refused session, and the
// timeline emitter takes each event's SessionId from this state.
var seedBuilder = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc);
seedBuilder.SessionId = sessionId;
seed = seedBuilder.Build();
signalsToReplay = CollectSignalLogTailAfter(signalLog, loadedState.LastAppliedSignalOrdinal);
branchTag = signalsToReplay.Count > 0 ? "b-tail-replay" : "b-snapshot-current";
}
@@ -1,6 +1,8 @@
using System;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
@@ -22,6 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private readonly string _sessionFilePath;
private readonly string _sessionCreatedFilePath;
private readonly string _whiteGloveMarkerPath;
private readonly string _registrationFailurePath;
private readonly object _lockObject = new object();
public SessionIdPersistence(string dataDirectory)
@@ -35,6 +38,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
_sessionCreatedFilePath = Path.Combine(dataDirectory, "session.created");
_whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete");
_registrationFailurePath = Path.Combine(dataDirectory, "registration-failure.json");
}
/// <summary>
@@ -164,6 +168,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
lock (_lockObject) TryDelete(_whiteGloveMarkerPath, logger);
}
/// <summary>
/// Folds one failed registration run into <c>registration-failure.json</c>: the run
/// counter grows, the first failure time is kept, everything else describes the latest
/// run. A run that never registers exits and leaves nothing else behind — the emergency
/// break reads this file so the backend learns why the session never appeared.
/// Best-effort; never throws.
/// </summary>
public void RecordRegistrationFailure(RegistrationFailureSummary latestRun, AgentLogger logger = null)
{
if (latestRun == null) return;
lock (_lockObject)
{
try
{
var previous = LoadRegistrationFailureInternal();
var merged = new RegistrationFailureSummary
{
FailedRuns = (previous?.FailedRuns ?? 0) + 1,
FirstFailedAtUtc = previous?.FirstFailedAtUtc ?? latestRun.LastFailedAtUtc,
LastFailedAtUtc = latestRun.LastFailedAtUtc,
Outcome = latestRun.Outcome,
LastError = latestRun.LastError,
NetworkLinkUpAtStart = latestRun.NetworkLinkUpAtStart,
NetworkLinkUpAtEnd = latestRun.NetworkLinkUpAtEnd,
AttemptWindowSeconds = latestRun.AttemptWindowSeconds,
ConfigFetchOutcome = latestRun.ConfigFetchOutcome,
};
File.WriteAllText(_registrationFailurePath, JsonConvert.SerializeObject(merged));
logger?.Info($"SessionIdPersistence: recorded failed registration run #{merged.FailedRuns}.");
}
catch (Exception ex)
{
logger?.Warning($"SessionIdPersistence: RecordRegistrationFailure failed: {ex.Message}");
}
}
}
/// <summary>The failed-registration record since the last success, or <c>null</c> (none, unreadable).</summary>
public RegistrationFailureSummary LoadRegistrationFailure()
{
lock (_lockObject) return LoadRegistrationFailureInternal();
}
/// <summary>Drops the failed-registration record — called once registration succeeds.</summary>
public void ClearRegistrationFailure(AgentLogger logger = null)
{
lock (_lockObject) TryDelete(_registrationFailurePath, logger);
}
/// <summary>Deletes the persisted SessionId. Next <see cref="GetOrCreate"/> starts a fresh session.</summary>
public void Delete(AgentLogger logger = null)
{
@@ -172,6 +225,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
TryDelete(_sessionFilePath, logger);
TryDelete(_sessionCreatedFilePath, logger);
TryDelete(_whiteGloveMarkerPath, logger);
TryDelete(_registrationFailurePath, logger);
}
}
@@ -191,11 +245,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var fresh = Guid.NewGuid().ToString();
WriteAtomic(fresh, logger);
SaveSessionCreatedAtInternal(DateTime.UtcNow);
// Earlier failed runs belonged to the refused session id.
TryDelete(_registrationFailurePath, logger);
logger?.Info($"SessionIdPersistence: rotated SessionId to {fresh}.");
return fresh;
}
}
private RegistrationFailureSummary LoadRegistrationFailureInternal()
{
try
{
if (!File.Exists(_registrationFailurePath)) return null;
var record = JsonConvert.DeserializeObject<RegistrationFailureSummary>(File.ReadAllText(_registrationFailurePath));
return record != null && record.FailedRuns > 0 ? record : null;
}
catch
{
// Corrupt record: report nothing rather than a half-read account.
return null;
}
}
private static void TryDelete(string path, AgentLogger logger)
{
try { if (File.Exists(path)) File.Delete(path); }
@@ -34,13 +34,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
/// without retrying — the backend has definitively rejected the device cert.</item>
/// <item>Exception to the above — a 403 carrying error code
/// <item>Exception to the above — a 403 or 409 carrying error code
/// <c>session_owner_mismatch</c> (SESSION-OWNER-BINDING): the persisted SessionId names a
/// session bound to another device identity (typically an Intune re-enrollment without a
/// wipe, where <c>session.id</c> survived but the certificate identity changed). That is
/// not an auth failure: the session is rotated through <c>rotateSession</c> and
/// registration retried exactly once. The tracker is NOT fed — five of these would
/// otherwise soft-shutdown a perfectly authorized agent.</item>
/// session that is not this device's — bound to another device identity (403, typically an
/// Intune re-enrollment without a wipe, where <c>session.id</c> survived but the certificate
/// identity changed) or claimed by another tenant (409). That is not an auth failure: the
/// session is rotated through <c>rotateSession</c> and registration retried exactly once.
/// The tracker is NOT fed — five of these would otherwise soft-shutdown a perfectly
/// authorized agent.</item>
/// <item>Any other coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
/// without retrying — a refusal with a code does not clear by waiting.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
@@ -113,14 +116,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
lastError = response?.Message ?? "(null response)";
logger.Warning($"Session registration failed: {lastError}");
}
catch (BackendAuthException ex) when (
ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch
catch (Exception ex) when (
IsSessionOwnerMismatch(ex)
&& rotateSession != null
&& rotatedFromSessionId == null)
{
// SESSION-OWNER-BINDING: the id on disk belongs to a session this device
// identity does not own. Become a new session and register once more —
// immediately, no backoff, no auth-failure bookkeeping.
// SESSION-OWNER-BINDING: the id on disk names a session this device does not
// own. Become a new session and register once more — immediately, no backoff,
// no auth-failure bookkeeping.
rotatedFromSessionId = registration.SessionId;
string newSessionId;
try
@@ -130,15 +133,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
catch (Exception rotateEx)
{
logger.Error($"Session owner mismatch reported by backend but session rotation failed: {rotateEx.Message}", rotateEx);
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
if (ex is BackendAuthException authEx)
{
authFailureTracker?.RecordFailure(authEx.StatusCode, "agent/register-session", authEx.EndpointUnavailable);
return SessionRegistrationResult.AuthFailed(authEx.StatusCode, authEx.Message);
}
return SessionRegistrationResult.Failed(ex.Message);
}
logger.Warning(
$"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " +
$"is bound to another device identity (re-enrollment without wipe?). Rotated to {newSessionId}; re-registering.");
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
$"Rotated to {newSessionId}; re-registering.");
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
continue;
}
catch (BackendRefusalException ex)
{
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
return SessionRegistrationResult.Failed(ex.Message);
}
catch (BackendAuthException ex)
{
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
@@ -195,6 +208,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded");
}
private static bool IsSessionOwnerMismatch(Exception ex)
{
var code = (ex as BackendAuthException)?.ErrorCode ?? (ex as BackendRefusalException)?.ErrorCode;
return code == Constants.AgentErrorCodes.SessionOwnerMismatch;
}
/// <summary>V1 parity exponential backoff: 2^attempt seconds.</summary>
private static Task DefaultBackoffDelay(int attempt)
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
@@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// <list type="bullet">
/// <item><c>HelloCompleted</c> → <see cref="DecisionSignalKind.HelloResolved"/></item>
/// <item><c>FinalizingSetupPhaseTriggered</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/> (phase=FinalizingSetup)</item>
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/> (payload: registry AccountSetup progress at that moment)</item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/> (merged aus ShellCore + Provisioning)</item>
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix — strong AccountSetup-done gate)</item>
@@ -46,6 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly EspAndHelloTracker _coordinator;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
private readonly Func<bool?> _accountSetupProgressProbe;
private bool _helloPosted;
private bool _helloWizardStartedPosted;
@@ -60,11 +61,14 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
public EspAndHelloTrackerAdapter(
EspAndHelloTracker coordinator,
ISignalIngressSink ingress,
IClock clock)
IClock clock,
Func<bool?>? accountSetupProgressProbe = null)
{
_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
// Test seam; production reads the provisioning tracker's registry-derived flag.
_accountSetupProgressProbe = accountSetupProgressProbe ?? (() => _coordinator.AccountSetupProgressIfRead);
_coordinator.HelloCompleted += OnHelloCompleted;
_coordinator.FinalizingSetupPhaseTriggered += OnFinalizing;
@@ -216,13 +220,29 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
if (_whiteGloveSuccessPosted) return;
_whiteGloveSuccessPosted = true;
// Registry AccountSetup progress at the moment of the success page. The sealing
// classifier lifts its AccountSetup excluder only on an explicit "false" — IME logs
// AccountSetup from the device session once the device apps are done, which is no
// user activity. Sampled here so replay reads it from the SignalLog (L.2/L.5). An
// unread registry sends no key: unknown must keep the excluder, never lift it.
var progress = _accountSetupProgressProbe();
var progressText = progress.HasValue ? (progress.Value ? "true" : "false") : null;
var now = ResolveOccurredAt(out var derivedFromClock);
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
{
["subSource"] = "ShellCoreTracker",
[SignalPayloadKeys.AccountSetupProgress] = progressText ?? "unread",
};
TagDerivedTimestamp(derivationInputs, derivedFromClock);
var payload = progressText == null
? null
: new Dictionary<string, string>(StringComparer.Ordinal)
{
[SignalPayloadKeys.AccountSetupProgress] = progressText,
};
_ingress.Post(
kind: DecisionSignalKind.WhiteGloveShellCoreSuccess,
occurredAtUtc: now,
@@ -231,7 +251,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
kind: EvidenceKind.Derived,
identifier: DetectorId,
summary: "WhiteGlove sealing success (coordinator-forwarded)",
derivationInputs: derivationInputs));
derivationInputs: derivationInputs),
payload: payload);
}
private void EmitEspFailure(EspFailureDetectedEventArgs args)
@@ -169,6 +169,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_logger.Info(
$"EnrollmentTerminationHandler: handling Terminated (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName}).");
// The seal is already persisted in the decision snapshot. The marker goes down
// before anything else: the technician's reseal can kill this process at any
// later step, and a missing marker makes the next boot run the 48-hour break
// against the Part-1 session age instead of resuming Part 2.
if (isWhiteGlovePart1)
{
TrySaveWhiteGloveComplete();
}
var state = TryGetCurrentState();
// M4.6.δ — shutdown analyzers run BEFORE the dialog / diagnostics upload so
@@ -254,7 +263,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
// WhiteGlove Part-1 exit: keep the session alive, but announce the handoff so
// the timeline clearly marks the transition. The `whiteglove.complete` marker
// lets the next agent boot classify itself as a Part-2 resume.
// (written at the top of this handler) lets the next agent boot classify itself
// as a Part-2 resume.
if (isWhiteGlovePart1)
{
// Stop peripheral collectors BEFORE the Part-1-complete marker so their
@@ -298,7 +308,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
DelayLateEventGrace();
DrainSpool();
TrySaveWhiteGloveComplete();
// Option 2 (WG Part 1 graceful-exit hardening, 2026-04-30): write the
// clean-exit marker BEFORE _signalShutdown returns control to the main
// thread. The AppDomain.ProcessExit handler still writes it as a second
@@ -230,6 +230,9 @@ namespace AutopilotMonitor.Agent.V2
logger.Info($"--run-gather-rules: debug trace -> {gatherDebugLogPath}");
if (consoleMode) Console.WriteLine($"Debug trace: {gatherDebugLogPath}");
// No stateDirectory on purpose: this mode runs beside the live agent under a
// throwaway session id and must neither resume nor overwrite the session's
// gather state (logparser positions, on_change values).
using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride,
debugLogPath: gatherDebugLogPath,
debugEcho: consoleMode ? (Action<string>)(line => Console.WriteLine($" {line}")) : null))
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Agent.V2
Func<CleanupService> cleanupServiceFactory,
AgentLogger logger,
bool consoleMode,
Action onBreakFired = null)
Action<double> onBreakFired = null)
{
try
{
@@ -347,7 +347,7 @@ namespace AutopilotMonitor.Agent.V2
// (tasks/enrollment-status-reclassification.md). Fired BEFORE cleanup, while the
// session state and network are still intact. Never throws — a send failure (e.g. no
// network) must not block the cleanup/exit that is the whole point of this guard.
try { onBreakFired?.Invoke(); }
try { onBreakFired?.Invoke(sessionAgeHours); }
catch (Exception cbEx) { logger.Debug($"Emergency-break notify callback failed: {cbEx.Message}"); }
// Write enrollment-complete.marker so the next start exits cleanly even if
@@ -273,7 +273,9 @@ namespace AutopilotMonitor.Agent.V2
var registration = Runtime.BackendSessionRegistration.Register(
bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger,
rotateSession: () => Runtime.BackendSessionRegistration.RotateSession(
bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger));
bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger),
sessionPersistence: bootstrap.SessionPersistence,
configFetchOutcome: runtimeConfig.RemoteConfigService.LastFetchOutcome.ToString());
if (registration.ShouldExit)
{
return registration.ExitCode;
@@ -87,7 +87,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
cleanupServiceFactory, logger, consoleMode,
// Best-effort: surface the otherwise-silent 48h break to the backend before cleanup
// (tasks/enrollment-status-reclassification.md). Never blocks the exit.
onBreakFired: () => EmergencyBreakReporter.TrySend(agentConfig, Program.GetAgentVersion(), logger)))
// The failed-registration record is read here, before the break deletes the
// session files.
onBreakFired: sessionAgeHours => EmergencyBreakReporter.TrySend(
agentConfig, Program.GetAgentVersion(), sessionAgeHours,
sessionPersistence.LoadRegistrationFailure(), logger)))
{
logger.Info("Emergency break fired — agent exiting.");
return BootstrapResult.Exit(0);
@@ -526,7 +526,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// analytics. WhiteGlove Part-2 resume runs as a fresh Classic
// enrollment after PR-A's archive-and-reset, so the engine state is
// empty and needs the facts re-seeded — same as a first boot.
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, logger);
LifecycleEmitters.PostEnrollmentFactsObserved(orchestrator.IngressSink, agentConfig.HelloWaitTimeoutSeconds, logger);
// V2 parity — post SessionStarted so the reducer establishes the session
// anchor (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped only
@@ -1,6 +1,7 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
@@ -17,7 +18,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <c>IncrementSessionEventCountAsync</c> / <c>UpdateSessionStatusAsync</c> silently
/// no-op and session status / phase / admin-overrides / validator reconcile all break.
/// On registration failure we follow V1's rule: collectors MUST NOT start and the
/// agent exits cleanly so the next Scheduled-Task tick can retry.
/// agent exits cleanly; the Scheduled Task has only a boot trigger, so the next attempt is
/// the next boot. The failed run is recorded for the emergency break report.
/// </summary>
internal static class BackendSessionRegistration
{
@@ -28,12 +30,19 @@ namespace AutopilotMonitor.Agent.V2.Runtime
string agentVersion,
bool consoleMode,
AgentLogger logger,
Func<string> rotateSession = null)
Func<string> rotateSession = null,
SessionIdPersistence sessionPersistence = null,
string configFetchOutcome = null)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
if (logger == null) throw new ArgumentNullException(nameof(logger));
// Wall clock on purpose: a window far above the retry schedule is the evidence that
// the device slept or was suspended while this run tried to register.
var startedUtc = DateTime.UtcNow;
var linkUpAtStart = TryIsNetworkLinkUp();
var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient: auth.BackendApiClient,
agentConfig: agentConfig,
@@ -48,6 +57,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
rotateSession: rotateSession)
.GetAwaiter().GetResult();
RecordRegistrationRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, logger);
if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)
{
logger.Error(
@@ -65,13 +78,71 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return SessionRegistrationOutcomeResult.Continue(registrationResult);
}
/// <summary>Longest <see cref="RegistrationFailureSummary.LastError"/> kept on disk and on the wire.</summary>
internal const int MaxRecordedErrorLength = 256;
/// <summary>
/// Keeps <c>registration-failure.json</c> in step with this run: a failed run is folded
/// into the record (the emergency break later reports it — for a session that never
/// registered, the only account of why), a successful one clears it. Never throws.
/// </summary>
internal static void RecordRegistrationRun(
SessionIdPersistence sessionPersistence,
SessionRegistrationResult registrationResult,
DateTime startedUtc,
DateTime endedUtc,
bool? linkUpAtStart,
bool? linkUpAtEnd,
string configFetchOutcome,
AgentLogger logger)
{
if (sessionPersistence == null || registrationResult == null) return;
try
{
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
{
sessionPersistence.ClearRegistrationFailure(logger);
return;
}
var error = registrationResult.ErrorMessage;
if (error != null && error.Length > MaxRecordedErrorLength)
error = error.Substring(0, MaxRecordedErrorLength);
sessionPersistence.RecordRegistrationFailure(new RegistrationFailureSummary
{
FailedRuns = 1,
FirstFailedAtUtc = endedUtc,
LastFailedAtUtc = endedUtc,
Outcome = registrationResult.Outcome.ToString(),
LastError = error,
NetworkLinkUpAtStart = linkUpAtStart,
NetworkLinkUpAtEnd = linkUpAtEnd,
AttemptWindowSeconds = Math.Round(Math.Max(0, (endedUtc - startedUtc).TotalSeconds), 1),
ConfigFetchOutcome = configFetchOutcome,
}, logger);
}
catch (Exception ex)
{
logger?.Warning($"Registration run record failed (best-effort): {ex.Message}");
}
}
private static bool? TryIsNetworkLinkUp()
{
try { return NetworkInterface.GetIsNetworkAvailable(); }
catch { return null; }
}
/// <summary>
/// SESSION-OWNER-BINDING: the one place that turns "backend says this session id is not
/// ours" into a new session. Rotates the persisted id, points the runtime config at it,
/// drops the transport spool (its lines carry the refused session's partition key and
/// would otherwise be uploaded against it after the rotation) and re-targets the
/// emergency reporter, which snapshotted the id before registration. Everything else
/// reads <c>agentConfig.SessionId</c> live after Phase 6.
/// emergency reporter, which snapshotted the id before registration. Everything else that
/// holds a session id (orchestrator, emitters, spool) is built in Phase 7 from the rotated
/// <c>agentConfig.SessionId</c> — keep it that way: a component built before Phase 6 needs
/// its own re-target here.
/// </summary>
internal static string RotateSession(
AgentConfiguration agentConfig,
@@ -36,15 +36,20 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Upper bound for the blocking wait: NIC wait + 3×15s attempts + 2×5s delays + slack.
private static readonly TimeSpan OverallBudget = TimeSpan.FromSeconds(75);
public static void TrySend(AgentConfiguration agentConfig, string agentVersion, AgentLogger logger)
public static void TrySend(
AgentConfiguration agentConfig,
string agentVersion,
double sessionAgeHours,
RegistrationFailureSummary priorRegistrationFailure,
AgentLogger logger)
{
try
{
WaitForNetwork(logger);
var auth = BackendClientFactory.BuildAuthClients(agentConfig, agentVersion, logger);
var message =
$"Agent absolute session-age emergency break fired (cap {agentConfig.AbsoluteMaxSessionHours}h) — cleaning up and exiting.";
var roundedAgeHours = Math.Round(sessionAgeHours, 1);
var message = BuildMessage(roundedAgeHours, agentConfig.AbsoluteMaxSessionHours, priorRegistrationFailure);
// The process is about to exit, so block on the otherwise fire-and-forget send
// rather than let it be abandoned mid-flight. TrySendAsync swallows its own HTTP
@@ -56,7 +61,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
message,
attempts: SendAttempts,
perAttemptTimeout: PerAttemptTimeout,
retryDelay: RetryDelay)
retryDelay: RetryDelay,
sessionAgeHours: roundedAgeHours,
priorRegistrationFailure: priorRegistrationFailure)
.Wait(OverallBudget);
}
catch (Exception ex)
@@ -65,6 +72,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
/// <summary>
/// Invariant culture: the text lands in the backend's ops feed and timeline, and a device
/// locale would otherwise print "478,2h". The registration clause names what the ops feed
/// cannot otherwise show for a session that never registered.
/// </summary>
internal static string BuildMessage(double roundedAgeHours, int capHours, RegistrationFailureSummary priorRegistrationFailure)
{
var message = FormattableString.Invariant(
$"Agent absolute session-age emergency break fired at session age {roundedAgeHours:F1}h (cap {capHours}h) — cleaning up and exiting.");
if (priorRegistrationFailure != null && priorRegistrationFailure.FailedRuns > 0)
{
message += FormattableString.Invariant(
$" {priorRegistrationFailure.FailedRuns} earlier start(s) failed to register (last: {priorRegistrationFailure.Outcome}, network link at end: {FormatLink(priorRegistrationFailure.NetworkLinkUpAtEnd)}).");
}
return message;
}
private static string FormatLink(bool? up) => up == null ? "unknown" : (up.Value ? "up" : "down");
/// <summary>
/// Boot-time NIC grace: the break fires during bootstrap, often seconds after boot while
/// Wi-Fi is still associating. Polls the cheap link-level signal — it cannot prove backend
@@ -314,7 +314,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> signal carrying the
/// registry-derived enrollment facts (<c>enrollmentType</c> + <c>isHybridJoin</c>)
/// so the reducer can seed <see cref="State.EnrollmentScenarioProfile"/> via the
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler.
/// stage-agnostic <c>HandleEnrollmentFactsObservedV1</c> handler — plus the tenant's
/// Hello wait when it extends the engine's built-in hello_safety window.
/// <para>
/// Posted immediately before <see cref="PostSessionStarted"/> so the Inspector
/// timeline reads naturally (facts → anchor) — but the reducer correctness does
@@ -329,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
public static void PostEnrollmentFactsObserved(
ISignalIngressSink ingressSink,
int helloWaitTimeoutSeconds,
AgentLogger logger)
{
try
@@ -352,6 +354,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
["isCloudPc"] = isCloudPc ? "true" : "false",
};
// Tenant Hello wait (remote config, not a registry fact) — stamped only when it
// widens the engine's built-in hello_safety window, so sessions on the default
// post the same payload as before.
var helloWaitSeconds = HelloWaitTimeout.EffectiveSeconds(helloWaitTimeoutSeconds);
if (helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
{
payload[SignalPayloadKeys.HelloWaitTimeoutSeconds] =
helloWaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
var evidence = new Evidence(
kind: EvidenceKind.Synthetic,
identifier: "enrollment_registry_facts_read",
@@ -364,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
evidence: evidence,
payload: payload);
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}, helloWaitSeconds={helloWaitSeconds}).");
}
catch (Exception ex)
{
@@ -0,0 +1,357 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Tenant-extendable Hello window. The agent stamps the tenant's <c>HelloWaitTimeoutSeconds</c>
/// on <see cref="DecisionSignalKind.EnrollmentFactsObserved"/> when it exceeds the built-in
/// 300 s; every <see cref="DeadlineNames.HelloSafety"/> arm site then uses it, clamped to
/// 3600 s. Without the key the window stays exactly the built-in 300 s.
/// </summary>
public sealed class HelloSafetyTenantWindowTests
{
private static readonly DateTime T0 = new DateTime(2026, 9, 29, 8, 0, 0, DateTimeKind.Utc);
private static readonly DateTime EspExit = T0.AddMinutes(6);
[Theory]
[InlineData(null, 300)]
[InlineData("1800", 1800)]
[InlineData("301", 301)]
[InlineData("3600", 3600)]
[InlineData("86400", 3600)] // beyond the ceiling — clamped
[InlineData("300", 300)] // not an extension — ignored (the agent never stamps it)
[InlineData("120", 300)]
public void Esp_exit_arms_hello_safety_with_the_tenant_window(string? helloWait, int expectedSeconds)
{
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
Assert.Equal(SessionStage.AwaitingHello, state.Stage);
var helloSafety = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
Assert.Equal(EspExit.AddSeconds(expectedSeconds), helloSafety.DueAtUtc);
}
[Fact]
public void Tenant_window_is_recorded_set_once_with_its_source_ordinal()
{
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit("1800"));
var fact = state.ScenarioObservations.HelloWaitTimeoutSeconds;
Assert.NotNull(fact);
Assert.Equal(1800, fact!.Value);
Assert.Equal(1, fact.SourceSignalOrdinal);
// A later re-post (every agent start posts the facts again) never re-bases the window.
var repost = engine.Reduce(state, Facts(20, EspExit.AddSeconds(30), "3600"));
Assert.Equal(1800, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
Assert.Equal(1, repost.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
Assert.Empty(repost.Effects);
Assert.Equal(
EspExit.AddSeconds(1800),
Assert.Single(repost.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
}
[Fact]
public void Census_surfaces_the_tenant_window_only_when_recorded()
{
var engine = new DecisionEngine();
var extended = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit("2700")));
Assert.Contains("hello_wait_extended", extended.SignalsSeen);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(extended.SignalEvidence["helloWaitExtended"]);
Assert.Equal(2700, evidence["seconds"]);
var builtIn = DecisionStateSignalCensus.Build(ReduceAll(engine, ClassicUntilEspExit(helloWait: null)));
Assert.DoesNotContain("hello_wait_extended", builtIn.SignalsSeen);
}
[Theory]
[InlineData("abc")]
[InlineData("")]
[InlineData("0")]
[InlineData("-5")]
public void Malformed_tenant_window_is_ignored(string helloWait)
{
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait));
Assert.Null(state.ScenarioObservations.HelloWaitTimeoutSeconds);
Assert.Equal(
EspExit.AddSeconds(300),
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
}
[Fact]
public void Late_tenant_window_extends_the_armed_hello_safety_from_its_original_anchor()
{
// A backfilled ESP exit armed the built-in window before the facts signal landed.
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
Assert.Equal(
EspExit.AddSeconds(300),
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800"));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
var extended = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
Assert.Equal(EspExit.AddSeconds(1800), extended.DueAtUtc);
Assert.Equal(DecisionSignalKind.DeadlineFired, extended.FiresSignalKind);
Assert.Equal(DeadlineNames.HelloSafety, extended.FiresPayload![SignalPayloadKeys.Deadline]);
// The live scheduler is told to replace its timer (same name → replaced).
var effect = Assert.Single(step.Effects);
Assert.Equal(DecisionEffectKind.ScheduleDeadline, effect.Kind);
Assert.Equal(EspExit.AddSeconds(1800), effect.Deadline!.DueAtUtc);
}
[Theory]
[InlineData("300")]
[InlineData("120")]
public void Late_tenant_window_not_longer_than_the_built_in_leaves_the_deadline_alone(string helloWait)
{
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), helloWait));
Assert.Empty(step.Effects);
Assert.Null(step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds);
Assert.Equal(
EspExit.AddSeconds(300),
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
}
[Fact]
public void Fire_of_the_replaced_built_in_deadline_is_dead_ended_after_the_late_extension()
{
// The old timer fired while the facts signal was still queued ahead of it: the facts
// step moves hello_safety out, then the queued fire of the replaced incarnation
// arrives. It must not time Hello out at the built-in window.
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
var builtInDue = EspExit.AddSeconds(300);
state = engine.Reduce(state, Facts(20, EspExit.AddSeconds(10), "1800")).NewState;
var stale = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.DeadlineFired, builtInDue.AddSeconds(2),
Fired(DeadlineNames.HelloSafety, builtInDue)));
Assert.False(stale.Transition.Taken);
Assert.Equal("hello_safety_stale_superseded_by_rearm", stale.Transition.DeadEndReason);
Assert.Empty(stale.Effects);
Assert.Null(stale.NewState.HelloResolvedUtc);
Assert.Equal(SessionStage.AwaitingHello, stale.NewState.Stage);
var extendedDue = EspExit.AddSeconds(1800);
Assert.Equal(
extendedDue,
Assert.Single(stale.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
// The fire of the current incarnation still times Hello out (desktop is in → Finalizing).
var current = engine.Reduce(stale.NewState, MakeSignal(22, DecisionSignalKind.DeadlineFired, extendedDue,
Fired(DeadlineNames.HelloSafety, extendedDue)));
Assert.Equal(SessionStage.Finalizing, current.NewState.Stage);
Assert.Equal("Timeout", current.NewState.HelloOutcome!.Value);
}
[Fact]
public void Late_tenant_window_after_the_built_in_expired_leaves_the_pending_fire_to_decide()
{
var engine = new DecisionEngine();
var state = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
var step = engine.Reduce(state, Facts(20, EspExit.AddSeconds(301), "1800"));
Assert.Empty(step.Effects);
Assert.Equal(
EspExit.AddSeconds(300),
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
// The fact is still recorded — later arm sites use it.
Assert.Equal(1800, step.NewState.ScenarioObservations.HelloWaitTimeoutSeconds!.Value);
}
[Fact]
public void Late_extension_is_replay_deterministic()
{
var engine = new DecisionEngine();
var signals = ClassicUntilEspExit(helloWait: null)
.Concat(new[] { Facts(20, EspExit.AddSeconds(10), "2400") })
.ToList();
var stepwise = ReduceAll(engine, signals);
var replayed = ReducerReplay.Replay(engine, Seed(), signals);
Assert.Equal(
Assert.Single(stepwise.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc,
Assert.Single(replayed.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
Assert.Equal(EspExit.AddSeconds(2400), replayed.Deadlines.Single(d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
}
[Fact]
public void Tenant_window_also_applies_to_the_AccountSetup_complete_deferred_promote()
{
// Strong gate arrives after the final exit and before the desktop: the deferred
// promote arms hello_safety at the gate signal — with the tenant window.
var engine = new DecisionEngine();
var state = ReduceAll(engine, new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
Facts(1, T0.AddSeconds(5), "2400"),
MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("DeviceSetup")),
MakeSignal(3, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" }),
MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(12), Phase("AccountSetup")),
MakeSignal(5, DecisionSignalKind.EspExiting, T0.AddMinutes(24)),
});
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
var gate = T0.AddMinutes(25);
var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.AccountSetupProvisioningComplete, gate));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.Equal(
gate.AddSeconds(2400),
Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety).DueAtUtc);
}
[Theory]
[InlineData(null, 30 * 60)]
[InlineData("1200", 30 * 60)] // a shorter tenant window never shortens the WDP backstop
[InlineData("3600", 3600)]
public void Device_preparation_backstop_is_never_shorter_than_the_tenant_window(string? helloWait, int expectedSeconds)
{
// WDP has no ESP, so hello_safety never arms; the desktop-armed backstop is the last
// net behind the tracker's Hello completion timer, which runs on the tenant budget.
var engine = new DecisionEngine();
var facts = new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v2",
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
};
if (helloWait != null)
facts[SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait;
var desktop = T0.AddMinutes(9);
var state = ReduceAll(engine, new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, facts),
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
});
Assert.Equal(
desktop.AddSeconds(expectedSeconds),
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion).DueAtUtc);
}
[Fact]
public void IsTenantHelloWaitPending_is_true_only_while_an_extended_window_is_armed_and_not_yet_due()
{
var engine = new DecisionEngine();
var awaiting = ReduceAll(engine, ClassicUntilEspExit("1800"));
var due = EspExit.AddSeconds(1800);
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, EspExit.AddMinutes(1)));
Assert.True(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(-1)));
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due));
Assert.False(DecisionEngine.IsTenantHelloWaitPending(awaiting, due.AddSeconds(1)));
Assert.False(DecisionEngine.IsTenantHelloWaitPending(null, EspExit));
Assert.False(DecisionEngine.IsTenantHelloWaitPending(Seed(), EspExit));
// The built-in window never counts: default tenants keep their stall reporting.
var builtIn = ReduceAll(engine, ClassicUntilEspExit(helloWait: null));
Assert.Contains(builtIn.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
Assert.False(DecisionEngine.IsTenantHelloWaitPending(builtIn, EspExit.AddMinutes(1)));
// Hello resolved → the deadline is cancelled → nothing pending any more.
var resolved = engine.Reduce(awaiting, MakeSignal(20, DecisionSignalKind.HelloResolved, EspExit.AddMinutes(10),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" })).NewState;
Assert.False(DecisionEngine.IsTenantHelloWaitPending(resolved, EspExit.AddMinutes(11)));
}
[Fact]
public void IsTenantHelloWaitPending_covers_the_device_preparation_backstop()
{
var engine = new DecisionEngine();
var desktop = T0.AddMinutes(9);
var state = ReduceAll(engine, new[]
{
MakeSignal(0, DecisionSignalKind.SessionStarted, T0),
MakeSignal(1, DecisionSignalKind.EnrollmentFactsObserved, T0, new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v2",
[SignalPayloadKeys.EnrollmentTypeDeterministic] = "true",
[SignalPayloadKeys.HelloWaitTimeoutSeconds] = "3600",
}),
MakeSignal(2, DecisionSignalKind.DesktopArrived, desktop),
});
Assert.True(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(59)));
Assert.False(DecisionEngine.IsTenantHelloWaitPending(state, desktop.AddMinutes(60)));
}
// ============================================================ helpers
private static DecisionState Seed() => DecisionState.CreateInitial("sess-hello-window", "tenant", T0);
/// <summary>
/// Classic user-driven flow up to the final ESP exit: strong gate and desktop in place,
/// Hello policy unknown — so the exit promotes to AwaitingHello and arms hello_safety.
/// </summary>
private static List<DecisionSignal> ClassicUntilEspExit(string? helloWait)
{
var signals = new List<DecisionSignal> { MakeSignal(0, DecisionSignalKind.SessionStarted, T0) };
if (helloWait != null)
signals.Add(Facts(1, T0.AddSeconds(5), helloWait));
signals.Add(MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), Phase("AccountSetup")));
signals.Add(MakeSignal(3, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(2)));
signals.Add(MakeSignal(4, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3)));
signals.Add(MakeSignal(5, DecisionSignalKind.EspExiting, EspExit));
return signals;
}
private static DecisionState ReduceAll(DecisionEngine engine, IEnumerable<DecisionSignal> signals)
{
var state = Seed();
foreach (var signal in signals)
state = engine.Reduce(state, signal).NewState;
return state;
}
private static DecisionSignal Facts(long ordinal, DateTime occurredAtUtc, string helloWait) =>
MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, occurredAtUtc,
new Dictionary<string, string> { [SignalPayloadKeys.HelloWaitTimeoutSeconds] = helloWait });
private static Dictionary<string, string> Fired(string deadlineName, DateTime dueAtUtc) =>
new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = deadlineName,
[SignalPayloadKeys.DeadlineDueAtUtc] = dueAtUtc.ToString("O", CultureInfo.InvariantCulture),
};
private static Dictionary<string, string> Phase(string phase) =>
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = phase };
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
payload: payload);
}
}
@@ -522,7 +522,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
.WithCloudPc(value: true, sourceSignalOrdinal: 7)
.WithAccountSetupProgressAtWhiteGloveSuccess(value: false, sourceSignalOrdinal: 8)
.WithHelloWaitTimeoutSeconds(value: 1800, sourceSignalOrdinal: 9))
.Build();
var json = StateSerializer.Serialize(state);
@@ -538,6 +540,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
Assert.True(o.CloudPc!.Value);
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
Assert.False(o.AccountSetupProgressAtWhiteGloveSuccess!.Value);
Assert.Equal(8, o.AccountSetupProgressAtWhiteGloveSuccess!.SourceSignalOrdinal);
Assert.Equal(1800, o.HelloWaitTimeoutSeconds!.Value);
Assert.Equal(9, o.HelloWaitTimeoutSeconds!.SourceSignalOrdinal);
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
Assert.Null(o.WhiteGloveSealingPatternSeen);
@@ -31,7 +31,6 @@ namespace AutopilotMonitor.DecisionCore.Tests
DecisionSignalKind.HelloResolved,
DecisionSignalKind.ImeUserSessionCompleted,
DecisionSignalKind.DeviceSetupProvisioningComplete,
DecisionSignalKind.WhiteGloveShellCoreSuccess,
DecisionSignalKind.WhiteGloveSealingPatternDetected,
DecisionSignalKind.AadUserJoinedLate,
DecisionSignalKind.DeviceInfoCollected,
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// The sealing classifier's AccountSetup excluder (-40) applies only when the IME reported
/// the AccountSetup phase AND the WhiteGlove_Success signal did not report an untouched
/// AccountSetup registry. IME logs "EspPhase: AccountSetup" from the device session once
/// the device apps are done — on pre-provisioning with the user ESP enabled that line
/// routinely precedes the technician's success page and used to leave Part 1 unsealed.
/// The gate may only ever lift the excluder, never add one.
/// </summary>
public sealed class WhiteGloveAccountSetupProgressGateTests
{
private static readonly DateTime T0 = new DateTime(2026, 9, 29, 7, 25, 9, DateTimeKind.Utc);
[Fact]
public void Ime_account_setup_with_untouched_registry_seals_on_the_fast_path()
{
var step = new DecisionEngine().Reduce(
StateWithImeAccountSetup(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: "false"));
Assert.Equal(SessionStage.WhiteGloveSealed, step.NewState.Stage);
Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, step.NewState.Outcome);
Assert.Equal(HypothesisLevel.Confirmed, step.NewState.ClassifierOutcomes.WhiteGloveSealing.Level);
Assert.Equal(WhiteGloveSealingClassifier.WeightShellCoreWhiteGloveSuccess,
step.NewState.ClassifierOutcomes.WhiteGloveSealing.Score);
var fact = step.NewState.ScenarioObservations.AccountSetupProgressAtWhiteGloveSuccess;
Assert.NotNull(fact);
Assert.False(fact!.Value);
Assert.Equal(3, fact.SourceSignalOrdinal);
}
[Fact]
public void Ime_account_setup_with_registry_progress_keeps_the_excluder()
{
var step = new DecisionEngine().Reduce(
StateWithImeAccountSetup(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: "true"));
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.RunClassifier);
Assert.True(DecisionEngine.BuildWhiteGloveSealingSnapshot(step.NewState).HasAccountSetupActivity);
}
[Fact]
public void Missing_progress_payload_keeps_the_excluder_for_older_agents()
{
var step = new DecisionEngine().Reduce(
StateWithImeAccountSetup(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: null));
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
Assert.Null(step.NewState.ScenarioObservations.AccountSetupProgressAtWhiteGloveSuccess);
Assert.True(DecisionEngine.BuildWhiteGloveSealingSnapshot(step.NewState).HasAccountSetupActivity);
}
[Theory]
[InlineData("")]
[InlineData("yes")]
public void Unparseable_progress_payload_is_treated_as_missing(string raw)
{
var step = new DecisionEngine().Reduce(
StateWithImeAccountSetup(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: raw));
Assert.Null(step.NewState.ScenarioObservations.AccountSetupProgressAtWhiteGloveSuccess);
Assert.NotEqual(SessionStage.WhiteGloveSealed, step.NewState.Stage);
}
[Fact]
public void Registry_progress_without_ime_account_setup_adds_no_excluder()
{
var state = DecisionState.CreateInitial("s-gate", "t-gate").ToBuilder()
.WithStage(SessionStage.EspDeviceSetup)
.WithStepIndex(1)
.WithLastAppliedSignalOrdinal(0)
.Build();
var step = new DecisionEngine().Reduce(state, MakeShellCoreSignal(ordinal: 1, accountSetupProgress: "true"));
Assert.Equal(SessionStage.WhiteGloveSealed, step.NewState.Stage);
}
[Fact]
public void Untouched_registry_does_not_lift_a_hard_excluder()
{
var builder = DecisionState.CreateInitial("s-gate", "t-gate").ToBuilder()
.WithStage(SessionStage.EspAccountSetup)
.WithStepIndex(3)
.WithLastAppliedSignalOrdinal(2);
builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(T0.AddMinutes(-3), 1);
builder.DesktopArrivedUtc = new SignalFact<DateTime>(T0.AddMinutes(-1), 2);
var step = new DecisionEngine().Reduce(builder.Build(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: "false"));
Assert.NotEqual(SessionStage.WhiteGloveSealed, step.NewState.Stage);
var snapshot = DecisionEngine.BuildWhiteGloveSealingSnapshot(step.NewState);
Assert.False(snapshot.HasAccountSetupActivity);
Assert.True(snapshot.DesktopArrived);
}
[Fact]
public void Progress_fact_survives_the_state_serializer()
{
var step = new DecisionEngine().Reduce(
StateWithImeAccountSetup(), MakeShellCoreSignal(ordinal: 3, accountSetupProgress: "false"));
var roundTripped = StateSerializer.Deserialize(StateSerializer.Serialize(step.NewState));
var fact = roundTripped.ScenarioObservations.AccountSetupProgressAtWhiteGloveSuccess;
Assert.NotNull(fact);
Assert.False(fact!.Value);
Assert.Equal(3, fact.SourceSignalOrdinal);
}
private static DecisionState StateWithImeAccountSetup()
{
// The IME device-session line: AccountSetup entered before the success page.
var builder = DecisionState.CreateInitial("s-gate", "t-gate").ToBuilder()
.WithStage(SessionStage.EspAccountSetup)
.WithStepIndex(3)
.WithLastAppliedSignalOrdinal(2);
builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(T0.AddMinutes(-3), 2);
return builder.Build();
}
private static DecisionSignal MakeShellCoreSignal(long ordinal, string? accountSetupProgress)
{
var payload = new Dictionary<string, string>(StringComparer.Ordinal);
if (accountSetupProgress != null)
{
payload[SignalPayloadKeys.AccountSetupProgress] = accountSetupProgress;
}
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: DecisionSignalKind.WhiteGloveShellCoreSuccess,
kindSchemaVersion: 1,
occurredAtUtc: T0,
sourceOrigin: "EspAndHelloTracker",
evidence: new Evidence(
kind: EvidenceKind.Raw,
identifier: "ShellCore-62407",
summary: "WhiteGlove_Success"),
payload: payload);
}
}
}
@@ -57,8 +57,6 @@ public class AdminConfigurationRateLimitSyncTests
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
// ...but NO tenant configuration is enumerated or mutated (sync removed).
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.SaveTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
repo.VerifyNoTenantConfigWrite();
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Functions.Services.Notifications;
@@ -32,6 +33,7 @@ public class AppHomingServiceTests
private readonly Mock<AdminConfigurationService> _adminConfigMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly StoredTenantConfig _store;
private readonly Mock<GraphTokenService> _graphTokenMock;
private readonly Mock<IGraphFeatureDetector> _detectorMock;
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
@@ -99,13 +101,10 @@ public class AppHomingServiceTests
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
// Defaults: flag on, legacy-homed config exists, both apps mint a role-less token (the
// superset rule passes trivially). FlipAsync reads via the cache-bypassing
// GetConfigurationFreshAsync (read-modify-write on the whole entity).
// superset rule passes trivially). FlipAsync writes through UpdateAsync (conditional
// write on the fresh row), stubbed over one stored row.
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
var config = LegacyHomedConfig();
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
_store = _tenantConfigMock.StubWrites(TenantId, LegacyHomedConfig());
SetupProbe(GraphTokenResult.Success(Jwt()));
SetupLegacyToken(GraphTokenResult.Success(Jwt()));
}
@@ -143,8 +142,7 @@ public class AppHomingServiceTests
It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
private void VerifyNeverSaved() =>
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
private void VerifyNeverSaved() => Assert.Empty(_store.Writes);
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
@@ -340,9 +338,10 @@ public class AppHomingServiceTests
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
var (written, source, _) = Assert.Single(_store.Writes);
Assert.Equal(PrimaryId, written.HomedAppClientId, ignoreCase: true);
Assert.Equal(Actor, written.UpdatedBy);
Assert.Equal("app-homing", source);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
@@ -359,7 +358,7 @@ public class AppHomingServiceTests
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome.Outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
VerifyNeverSaved();
}
[Fact]
@@ -370,7 +369,7 @@ public class AppHomingServiceTests
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome.Outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
VerifyNeverSaved();
}
[Fact]
@@ -394,7 +393,7 @@ public class AppHomingServiceTests
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome.Outcome);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
VerifyNeverSaved();
}
// ── FlipAsync ───────────────────────────────────────────────────────────
@@ -404,7 +403,7 @@ public class AppHomingServiceTests
{
var config = LegacyHomedConfig();
config.EntraAppRolesEnabled = true;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_store.Row = config;
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
@@ -417,11 +416,11 @@ public class AppHomingServiceTests
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_store.Row = config;
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
VerifyNeverSaved();
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
@@ -430,30 +429,68 @@ public class AppHomingServiceTests
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
{
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
// the fresh read returning null is a hard stop.
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
.ReturnsAsync((TenantConfiguration?)null);
// a missing row is a hard stop.
_store.Row = null;
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
VerifyNeverSaved();
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_decides_on_the_stored_row_not_the_callers_view()
{
// The caller's (cached) view is legacy-homed; the stored row was flipped meanwhile.
var stored = LegacyHomedConfig();
stored.HomedAppClientId = PrimaryId;
_store.Row = stored;
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "consent-auto-flip");
VerifyNeverSaved();
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_skips_a_tenant_being_offboarded()
{
var tombstone = LegacyHomedConfig();
tombstone.Disabled = true;
tombstone.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
_store.Row = tombstone;
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
VerifyNeverSaved();
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_throws_when_every_conditional_write_lost()
{
_store.Force = TenantConfigUpdateStatus.Conflict;
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
Assert.Empty(_savedOpsEvents);
}
[Fact]
public async Task Flip_back_to_legacy_persists_null_homing()
{
var config = LegacyHomedConfig();
config.HomedAppClientId = PrimaryId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
_store.Row = config;
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
Assert.Null(Assert.Single(_store.Writes).Row.HomedAppClientId);
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
}
}
@@ -306,7 +306,7 @@ public class AppInstallIdentityAndEspBlockingTests
};
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
var stats = root.GetProperty("summary");
@@ -325,7 +325,7 @@ public class AppInstallIdentityAndEspBlockingTests
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, repo.Object, "Company Portal", days: 30));
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
@@ -195,6 +195,37 @@ public class AppVersionRegressionRadarTests
Assert.Empty(AppVersionRegressionRadar.Evaluate(rows2));
}
[Fact]
public void Evaluate_SameNameInTwoChannels_IsTwoApps()
{
// The RealmJoin package regressed; the Intune app of the same name did not. A shared
// group would dilute the RealmJoin medians with the Intune rows and hide the regression.
var rows = new List<AppInstallSummary>();
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
rows.AddRange(Batch("1.0", 30, 300, Now.AddDays(-20)));
rows.AddRange(Batch("2.0", 30, 300, Now.AddDays(-5)));
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
Assert.Equal(AppInstallSources.RealmJoin, finding.Source);
Assert.Equal(App, finding.AppName);
Assert.Equal(900, finding.CurrentMedianSeconds);
}
[Fact]
public void ShouldReArm_ReadsTheAlertsOwnChannel()
{
// The IME app still regresses; the RealmJoin episode's version has no measured installs.
var rows = new List<AppInstallSummary>();
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)));
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)));
var rjAlert = Alert();
rjAlert.Source = AppInstallSources.RealmJoin;
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, rjAlert));
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
}
[Fact]
public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal()
{
@@ -279,13 +310,25 @@ public class AppVersionRegressionRadarTests
{
Assert.Equal("appversionregression|contoso app_2|1.0_beta_",
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(
" Contoso App/2 ", " 1.0#Beta? "));
AppInstallSources.Ime, " Contoso App/2 ", " 1.0#Beta? "));
}
[Fact]
public void AppVersionRegressionRowKey_ImeKeepsPreChannelShape_OtherChannelsPrefixTheApp()
{
// Episodes written before channels existed must keep resolving (re-arm deletes them by key).
Assert.Equal("appversionregression|contoso vpn|2.0",
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(null, App, "2.0"));
Assert.Equal("appversionregression|contoso vpn|2.0",
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(string.Empty, App, "2.0"));
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.0",
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "2.0"));
}
[Fact]
public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces()
{
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0");
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "1.0");
Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal);
// The prefix range [prefix, "appversionregression}") must not swallow the sibling
// keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}").
@@ -293,6 +336,39 @@ public class AppVersionRegressionRadarTests
Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0);
}
[Fact]
public void AppVersionRegressionEntity_RealmJoinChannel_RoundTripsWithPrefixedKey()
{
var alert = new AppVersionRegressionAlert
{
TenantId = TenantA,
AppName = App,
Source = AppInstallSources.RealmJoin,
CurrentVersion = "2.4.0",
PreviousVersion = "2.3.9",
FirstNotifiedAt = Now,
LastEvaluatedAt = Now,
};
var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert);
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.4.0", entity.RowKey);
Assert.Equal(AppInstallSources.RealmJoin,
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
}
[Fact]
public void AppVersionRegressionEntity_RowWithoutSourceColumn_MapsToIme()
{
var entity = new Azure.Data.Tables.TableEntity(TenantA.ToLowerInvariant(), "appversionregression|contoso vpn|2.4.0")
{
["TenantId"] = TenantA,
["AppName"] = App,
["CurrentVersion"] = "2.4.0",
};
Assert.Equal(AppInstallSources.Ime,
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
}
[Fact]
public void AppVersionRegressionEntity_RoundTripsAllFields()
{
@@ -318,6 +394,7 @@ public class AppVersionRegressionRadarTests
var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity);
Assert.Equal(TenantA, mapped.TenantId);
Assert.Equal(App, mapped.AppName);
Assert.Equal(AppInstallSources.Ime, mapped.Source);
Assert.Equal("2.4.0", mapped.CurrentVersion);
Assert.Equal("2.3.9", mapped.PreviousVersion);
Assert.Equal(1740, mapped.CurrentMedianSeconds);
@@ -371,7 +448,7 @@ public class AppVersionRegressionRadarTests
};
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
rows, sessionRepo.Object, App, days: 30));
rows, sessionRepo.Object, App, AppInstallSources.Ime, days: 30));
var version = root.GetProperty("versionBreakdown")[0];
Assert.Equal("1.0", version.GetProperty("appVersion").GetString());
@@ -395,12 +472,12 @@ public class AppVersionRegressionRadarTests
// Empty-data early return still surfaces the block (episodes can outlive window data).
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
new List<AppInstallSummary>(), sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
new List<AppVersionRegressionAlert> { alert }));
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, days: 30,
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
new List<AppVersionRegressionAlert> { alert }));
var regression = root.GetProperty("versionRegressions")[0];
@@ -131,7 +131,7 @@ public class AppsAnalyticsFailureRateTests
sessionRepo = mock.Object;
}
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, sessionRepo, "Contoso App", days: 30);
summaries, sessionRepo, "Contoso App", AppInstallSources.Ime, days: 30);
return TestWire.SerializeToElement(result);
}
@@ -31,9 +31,9 @@ public class AuditLogDeletionExclusionTests
public void Global_fanout_filter_includes_deletion_exclusion_only_when_requested()
{
var without = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: false);
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: false);
var with = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
Assert.DoesNotContain("deletion_started", without);
Assert.Contains("Action ne 'deletion_started'", with);
@@ -47,7 +47,7 @@ public class AuditLogDeletionExclusionTests
// noise leak back into one of the two views.
var tenant = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
var global = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
var clause = TableStorageService.DeletionExclusionClause();
Assert.Contains(clause, tenant);
@@ -51,7 +51,7 @@ public class AuditLogFieldFilterTests
var filters = new AuditLogQueryFilters { Action = "device_blocked", EntityType = "Device" };
var f = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
Tenant, null, null, lastRowKey: null, excludeDeletions: false, filters);
Tenant, null, null, rowKeyBound: null, excludeDeletions: false, filters);
Assert.Contains("Action eq 'device_blocked'", f);
Assert.Contains("EntityType eq 'Device'", f);
@@ -1,5 +1,9 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Functions.Infrastructure;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Tests.Offboarding;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Offboarding;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -32,6 +36,8 @@ public class AuthFunctionSideEffectTests
private readonly Mock<IMetricsRepository> _metricsRepoMock;
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
private readonly FakeSignalRNotificationService _signalR;
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
private readonly StoredTenantConfig _store;
private readonly AuthFunction _sut;
public AuthFunctionSideEffectTests()
@@ -108,12 +114,12 @@ public class AuthFunctionSideEffectTests
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
_signalR,
adminConfigService.Object);
adminConfigService.Object,
_offboardingRepo);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
// a brand-new tenant). Default: all fire-and-forget calls succeed.
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
@@ -202,7 +208,7 @@ public class AuthFunctionSideEffectTests
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@@ -222,7 +228,11 @@ public class AuthFunctionSideEffectTests
Assert.Equal(Upn, config.OnboardedBy);
// The DPA version rides along in the same onboarding write (D-252).
Assert.Equal(CurrentDpa, config.DpaVersion);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
var (written, source, _) = Assert.Single(_store.Writes);
Assert.Equal("contoso.com", written.DomainName);
Assert.Equal(Upn, written.OnboardedBy);
Assert.Equal(CurrentDpa, written.DpaVersion);
Assert.Equal("auth", source);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Tenant Signup",
@@ -237,6 +247,122 @@ public class AuthFunctionSideEffectTests
It.IsAny<CancellationToken>()), Times.Once);
}
// -------------------------------------------------------------------------
// Returning tenant (D-288): the offboarding deleted the configuration, the pointer
// carries the consumed self-service trial into the first-login write.
// -------------------------------------------------------------------------
private void SeedOffboardingPointer(bool trialConsumed) =>
_offboardingRepo.Pointers[TenantId] = (new OffboardingByTenantPointer
{
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
RowKey = TenantId,
TenantId = TenantId,
LatestStatus = "Completed",
OffboardCount = 1,
TrialConsumed = trialConsumed,
}, "\"etag\"");
private static TenantConfiguration ReturningTenantDefault()
{
// What auth/me hands over once the offboarding deleted the row: an unpersisted default.
var config = TenantConfiguration.CreateDefault(TenantId);
Assert.False(config.TrialConsumed);
return config;
}
[Fact]
public async Task HandleNewTenantDomain_ReturningTenantThatUsedTheTrial_CarriesItIntoTheOnboardingWrite()
{
SeedOffboardingPointer(trialConsumed: true);
var config = ReturningTenantDefault();
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.True(config.TrialConsumed);
Assert.Equal(Upn, config.OnboardedBy);
var written = Assert.Single(_store.Writes).Row;
Assert.True(written.TrialConsumed);
Assert.Equal(Upn, written.OnboardedBy);
// Re-onboarding itself stays unrestricted: same signup path, auto-approve included.
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Once);
// The verify criterion of the finding: the trial gate refuses the returning tenant.
var deny = PlanManagementFunction.EvaluateTrialStart(config, DateTime.UtcNow);
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, deny?.Code);
}
[Theory]
[InlineData(false)] // offboarded without ever using the trial
[InlineData(null)] // never offboarded
public async Task HandleNewTenantDomain_NoConsumedTrialOnRecord_LeavesTheTrialAvailable(bool? pointerTrialConsumed)
{
if (pointerTrialConsumed is { } consumed)
SeedOffboardingPointer(consumed);
var config = ReturningTenantDefault();
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.False(config.TrialConsumed);
Assert.Equal("contoso.com", config.DomainName);
Assert.False(Assert.Single(_store.Writes).Row.TrialConsumed);
}
[Fact]
public async Task HandleNewTenantDomain_OffboardingLookupFails_WritesNothingAndRetriesOnTheNextLogin()
{
// An onboarded row without the carry-over would hand the trial back; no write at all instead.
_offboardingRepo.ThrowOnPointerRead = new Azure.RequestFailedException(503, "Server busy");
var config = ReturningTenantDefault();
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task HandleNewTenantDomain_ConcurrentFirstLogin_NeverOverwritesOnboardedBy()
{
// This login's view still has no domain, but another login seeded the stored row meanwhile:
// OnboardedBy is write-once (D-061), and the signup side effects belong to the winner.
var stored = DefaultConfig();
stored.DomainName = "contoso.com";
stored.OnboardedBy = "first@contoso.com";
_store.Row = stored;
var view = DefaultConfig();
await _sut.HandleNewTenantDomainAsync(view, TenantId, "second@contoso.com");
Assert.Empty(_store.Writes);
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task HandleNewTenantDomain_OffboardingTombstone_IsNeverSeeded()
{
// The offboard click writes a Disabled tombstone even onto a row without a domain.
var tombstone = DefaultConfig();
tombstone.Disabled = true;
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
_store.Row = tombstone;
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// -------------------------------------------------------------------------
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
// read, never the (possibly stale) cached view. Regression for the prod
@@ -261,16 +387,16 @@ public class AuthFunctionSideEffectTests
fresh.LastAuthClientId = LegacyAppId;
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
_store.Row = fresh;
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
Assert.NotNull(fresh.LastAuthClientIdSince);
// The FRESH row was mutated and written — the flip survives, the stale view is discarded.
var written = Assert.Single(_store.Writes).Row;
Assert.Equal(PrimaryAppId, written.HomedAppClientId);
Assert.Equal(PrimaryAppId, written.LastAuthClientId);
Assert.NotNull(written.LastAuthClientIdSince);
Assert.Null(cached.HomedAppClientId);
}
[Fact]
@@ -286,11 +412,11 @@ public class AuthFunctionSideEffectTests
fresh.DomainName = "contoso.com";
fresh.LastAuthClientId = PrimaryAppId;
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
_store.Row = fresh;
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -303,8 +429,9 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_tenantConfigMock.Verify(x => x.UpdateAsync(It.IsAny<string>(), It.IsAny<Func<TenantConfiguration, bool>>(),
It.IsAny<string>(), It.IsAny<string>()), Times.Never);
Assert.Empty(_store.Writes);
}
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
@@ -321,7 +448,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.Null(config.DpaVersion);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -332,10 +459,12 @@ public class AuthFunctionSideEffectTests
var config = DefaultConfig();
config.DomainName = null!;
config.OnboardedBy = "original.requester@contoso.com";
_store.Row = ConfigRepoMockExtensions.Clone(config);
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.Equal("original.requester@contoso.com", config.OnboardedBy);
Assert.Equal("original.requester@contoso.com", Assert.Single(_store.Writes).Row.OnboardedBy);
}
[Fact]
@@ -346,7 +475,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
@@ -358,7 +487,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -369,7 +498,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -385,8 +514,8 @@ public class AuthFunctionSideEffectTests
// Should not throw — Telegram is fire-and-forget
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
// SaveConfig should still have been called before the fire-and-forget
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
// The write still landed before the fire-and-forget
Assert.Single(_store.Writes);
}
// -------------------------------------------------------------------------
@@ -400,14 +529,68 @@ public class AuthFunctionSideEffectTests
config.Disabled = true;
config.DisabledReason = "Maintenance";
config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired
_store.Row = ConfigRepoMockExtensions.Clone(config);
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.False(config.Disabled);
Assert.Null(config.DisabledReason);
Assert.Null(config.DisabledUntil);
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
var written = Assert.Single(_store.Writes).Row;
Assert.False(written.Disabled);
Assert.Null(written.DisabledUntil);
Assert.Equal("System (auto-re-enable)", written.UpdatedBy);
}
[Fact]
public async Task HandleAutoReEnable_SuspensionRenewedMeanwhile_StaysSuspended()
{
// This instance cached the expired suspension; a Global Admin renewed it since.
var view = DefaultConfig();
view.Disabled = true;
view.DisabledReason = "Maintenance";
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
var stored = ConfigRepoMockExtensions.Clone(view);
stored.DisabledReason = "Abuse review";
stored.DisabledUntil = DateTime.UtcNow.AddDays(2);
_store.Row = stored;
await _sut.HandleAutoReEnableAsync(view, TenantId);
Assert.Empty(_store.Writes);
Assert.True(view.IsCurrentlyDisabled());
Assert.Equal("Abuse review", view.DisabledReason);
}
[Fact]
public async Task HandleAutoReEnable_RowIsTheOffboardingTombstone_StaysSuspended()
{
var view = DefaultConfig();
view.Disabled = true;
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
var tombstone = ConfigRepoMockExtensions.Clone(view);
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
tombstone.DisabledUntil = null;
_store.Row = tombstone;
await _sut.HandleAutoReEnableAsync(view, TenantId);
Assert.Empty(_store.Writes);
Assert.True(view.IsCurrentlyDisabled());
}
[Fact]
public async Task HandleAutoReEnable_StorageFails_ThisLoginStillPasses()
{
var view = DefaultConfig();
view.Disabled = true;
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
_store.ThrowOnRead = new Azure.RequestFailedException(503, "Server busy");
await _sut.HandleAutoReEnableAsync(view, TenantId);
Assert.False(view.Disabled);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -420,7 +603,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -431,7 +614,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
[Fact]
@@ -444,7 +627,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleAutoReEnableAsync(config, TenantId);
Assert.True(config.Disabled);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
Assert.Empty(_store.Writes);
}
// -------------------------------------------------------------------------
@@ -63,7 +63,7 @@ public class AuthenticationMiddlewareTests
[InlineData("GET", "/api/progress/sessions/lookup")] // AuthenticatedUserWithRole
[InlineData("GET", "/api/sessions")] // MemberRead
[InlineData("GET", "/api/audit/logs")] // MemberRead
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
[InlineData("PATCH", "/api/config/11111111-1111-1111-1111-111111111111/fields")] // TenantAdminOrGA
[InlineData("GET", "/api/global/sessions")] // GlobalReadOrDelegatedSubset
[InlineData("POST", "/api/global/raw/logs")] // GlobalAdminOnly (KQL proxy)
[InlineData("GET", "/api/health/detailed")] // AuthenticatedUser (sub-route is NOT blanket-exempt)
@@ -207,8 +207,8 @@ public class BuiltInRulesTests
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
/// pinned here against the real IME line (verified against IME 1.104.102.0) because a
/// silent drift would bring back
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
/// </summary>
[Fact]
@@ -152,7 +152,7 @@ public class BusinessTimestampFilterClauseTests
var plain = TableStorageService.BuildAuditLogFilter(Tenant, from, to);
var fanOut = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
Tenant, from, to, lastRowKey: "!0123_x", excludeDeletions: false);
Tenant, from, to, rowKeyBound: new PerPartitionFanOutMerge.RowKeyBound("!0123_x", Inclusive: false), excludeDeletions: false);
foreach (var f in new[] { plain!, fanOut })
{
@@ -1,3 +1,5 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Functions.Security;
@@ -13,6 +15,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// must validate successfully. This guards against rollouts where the embedded root/intermediate
/// bundle drifts from the chain Microsoft is actually issuing.
/// - Negative path: self-signed leaf certs (with or without Intune-shaped DNs) must NOT validate.
/// - No network I/O: a leaf from an unknown CA must be rejected without fetching its AIA URL.
/// </summary>
public class CertificateValidatorTests
{
@@ -131,6 +134,31 @@ public class CertificateValidatorTests
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ValidateCertificate_WithUnknownIssuerAndAiaUrl_NeverFetchesTheIssuer()
{
// The caller mints the leaf, so the AIA caIssuers URL points wherever they like. A
// self-signed leaf never reaches the issuer-download path; it takes a leaf signed by a
// CA that is neither embedded nor sent.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var b64 = CreateLeafFromUnknownCaBase64($"http://127.0.0.1:{port}/{Guid.NewGuid():N}.cer");
var result = CertificateValidator.ValidateCertificate(b64);
Assert.False(result.IsValid);
Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(listener.Pending(), "Chain building connected to the certificate's AIA URL");
}
finally
{
listener.Stop();
}
}
private static string ResolveSamplePath()
{
var assemblyDir = Path.GetDirectoryName(typeof(CertificateValidatorTests).Assembly.Location)!;
@@ -165,4 +193,24 @@ public class CertificateValidatorTests
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
}
private static string CreateLeafFromUnknownCaBase64(string aiaCaIssuersUrl)
{
using var caKey = RSA.Create(2048);
var caReq = new CertificateRequest("CN=Unknown Test CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using var ca = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
using var leafKey = RSA.Create(2048);
var leafReq = new CertificateRequest("CN=test-device.contoso.example", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
leafReq.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
critical: false));
leafReq.CertificateExtensions.Add(
new X509AuthorityInformationAccessExtension(ocspUris: null, caIssuersUris: new[] { aiaCaIssuersUrl }));
using var leaf = leafReq.Create(ca, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddDays(30), RandomNumberGenerator.GetBytes(16));
return Convert.ToBase64String(leaf.Export(X509ContentType.Cert));
}
}
@@ -40,14 +40,23 @@ public class CollectLogsQuickConfigOpsEventTests
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
[Theory]
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData(null, "portal-put")]
[InlineData("", "portal-put")]
[InlineData("something-else", "portal-put")]
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
[InlineData("?intent=collect-logs", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
[InlineData("?intent=COLLECT-LOGS", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
[InlineData("", "patch", "api-patch")]
[InlineData("?intent=", "patch", "api-patch")]
[InlineData("?intent=something-else", "patch", "api-patch")]
[InlineData("?intent=collect-logs", "revert", "api-revert")] // a revert is never the quick-config dialog
public void ResolveSource_only_honours_the_allow_listed_intent(string query, string operation, string expected)
{
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
var (req, _) = EndpointHarness.Request("t1", queryString: query);
Assert.Equal(expected, PatchTenantConfigurationFieldsFunction.ResolveSource(req, operation));
}
[Fact]
public void ResolveSource_mcp_header_without_intent_is_mcp()
{
var (req, _) = EndpointHarness.Request("t1", headers: new Dictionary<string, string> { ["X-Client-Source"] = "mcp" });
Assert.Equal("mcp-patch", PatchTenantConfigurationFieldsFunction.ResolveSource(req, "patch"));
}
[Fact]
@@ -56,7 +65,7 @@ public class CollectLogsQuickConfigOpsEventTests
var (service, saved) = Rig();
await service.RecordDiagnosticsUploadConfigChangedAsync(
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
var evt = Assert.Single(saved);
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
@@ -85,7 +94,7 @@ public class CollectLogsQuickConfigOpsEventTests
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
}
@@ -56,6 +56,12 @@ public class ConfigBackupHookTests
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
@@ -109,13 +115,13 @@ public class ConfigBackupHookTests
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", "test change");
Assert.True(saved);
Assert.Single(harness.Upserts); // the config row itself
Assert.NotNull(snapshot);
Assert.Equal(TenantId, snapshot!.PartitionKey);
Assert.Equal("portal-put", snapshot.Source);
Assert.Equal("api-patch", snapshot.Source);
Assert.Equal("test change", snapshot.Reason);
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
// The snapshot holds the STORED row (retention 30), not the incoming one.
@@ -130,11 +136,11 @@ public class ConfigBackupHookTests
}
[Fact]
public async Task RowCreation_NoExistingRow_NoSnapshot()
public async Task RowCreation_ConditionalInsert_NoSnapshot()
{
var harness = new Harness(stored: null);
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
var saved = await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig());
Assert.True(saved);
Assert.Single(harness.Upserts);
@@ -144,6 +150,16 @@ public class ConfigBackupHookTests
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RowCreation_RowAlreadyExists_ReturnsFalse()
{
var harness = new Harness(stored: null);
harness.Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null));
Assert.False(await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig()));
}
[Fact]
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
{
@@ -157,7 +173,7 @@ public class ConfigBackupHookTests
incoming.LastUpdated = DateTime.UtcNow;
incoming.UpdatedBy = "System (auth)";
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "auth", null);
Assert.True(saved); // the save itself still goes through
Assert.Single(harness.Upserts);
@@ -171,7 +187,7 @@ public class ConfigBackupHookTests
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(RoundtripClone(stored), "\"etag\"", "api-patch", null);
Assert.True(saved);
harness.Backup.Verify(
@@ -190,14 +206,14 @@ public class ConfigBackupHookTests
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", null);
Assert.True(saved);
Assert.Single(harness.Upserts); // the config write went through regardless
}
[Fact]
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
public async Task NoSource_StillSnapshots_WithUnknownSource()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
@@ -210,7 +226,7 @@ public class ConfigBackupHookTests
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
await harness.Sut.SaveTenantConfigurationAsync(incoming);
await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", null, null);
Assert.NotNull(snapshot);
Assert.Equal("unknown", snapshot!.Source);
@@ -0,0 +1,132 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
internal static class ConfigRepoMockExtensions
{
/// <summary>
/// Asserts that no tenant configuration row was created or replaced — the only two ways the
/// repository can write one (D-290).
/// </summary>
public static void VerifyNoTenantConfigWrite(this Mock<IConfigRepository> repo)
{
repo.Verify(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
/// <summary>
/// Backs <see cref="TenantConfigurationService.UpdateAsync"/> and
/// <see cref="TenantConfigurationService.CreateOrUpdateAsync"/> of a service mock with one stored row
/// and the real contract: the mutation sees a fresh copy (never the caller's object), the offboarding
/// tombstone is refused, a declined mutation writes nothing, and every write is recorded.
/// </summary>
public static StoredTenantConfig StubWrites(this Mock<TenantConfigurationService> service, string tenantId, TenantConfiguration? row)
{
var store = new StoredTenantConfig { Row = row };
Task<TenantConfigUpdate> Run(Func<TenantConfiguration, bool> mutate, string source, string reason, bool createIfMissing)
{
if (store.Force is { } forced)
return Task.FromResult(new TenantConfigUpdate(forced, null));
if (store.ThrowOnRead is { } ex)
throw ex;
TenantConfiguration fresh;
if (store.Row == null)
{
if (!createIfMissing)
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null));
fresh = TenantConfiguration.CreateDefault(tenantId);
}
else
{
fresh = Clone(store.Row);
if (TenantOffboardFunction.IsOffboardingTombstone(fresh))
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, fresh));
}
if (!mutate(fresh))
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, fresh));
fresh.LastUpdated = DateTime.UtcNow;
store.Row = fresh;
store.Writes.Add((fresh, source, reason));
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, fresh));
}
service.Setup(x => x.UpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, false));
service.Setup(x => x.CreateOrUpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, true));
return store;
}
/// <summary>
/// Backs the tenant-configuration reads and writes of a repository mock with one stored row and
/// the storage contract: every read hands back a fresh copy, the replace succeeds only with the
/// current ETag, the insert only while no row exists, and every write is recorded.
/// </summary>
public static StoredTenantConfig BackWithRow(this Mock<IConfigRepository> repo, TenantConfiguration? row)
{
var store = new StoredTenantConfig { Row = row };
var version = 0;
string ETag() => $"etag-{version}";
TenantConfiguration? Read()
{
if (store.ThrowOnRead is { } ex) throw ex;
return store.Row == null ? null : Clone(store.Row);
}
bool Write(TenantConfiguration config, string source, string reason)
{
if (store.ThrowOnWrite is { } ex) throw ex;
if (store.RejectWrites) return false;
store.Row = Clone(config);
version++;
store.Writes.Add((store.Row, source, reason));
return true;
}
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => Read());
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
.ReturnsAsync(() => Read() is { } fresh ? (fresh, ETag()) : null);
repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((TenantConfiguration c, string etag, string? source, string? reason) =>
store.Row != null && etag == ETag() && Write(c, source ?? "", reason ?? ""));
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
.ReturnsAsync((TenantConfiguration c) => store.Row == null && Write(c, "(create)", ""));
return store;
}
/// <summary>A storage round trip: what a fresh read of the stored row hands back.</summary>
public static TenantConfiguration Clone(TenantConfiguration config)
=> TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config));
}
/// <summary>The stored row behind <see cref="ConfigRepoMockExtensions.StubWrites"/> and what was written to it.</summary>
internal sealed class StoredTenantConfig
{
public TenantConfiguration? Row { get; set; }
public List<(TenantConfiguration Row, string Source, string Reason)> Writes { get; } = new();
/// <summary>When set, every write returns this status without touching the row (e.g. Conflict).</summary>
public TenantConfigUpdateStatus? Force { get; set; }
/// <summary>When set, every read (and, for the service stub, every write) throws it — a storage outage.</summary>
public Exception? ThrowOnRead { get; set; }
/// <summary>Repository double only: when set, every write throws it.</summary>
public Exception? ThrowOnWrite { get; set; }
/// <summary>Repository double only: every conditional write loses (as if a concurrent writer always won).</summary>
public bool RejectWrites { get; set; }
}
@@ -713,31 +713,6 @@ public class ConfigWireParityTests
// ---- UpdateTenantConfiguration -------------------------------------------------------
[Fact]
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
{
var config = new TenantConfiguration
{
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
};
AssertParity(
new
{
success = true,
message = "Configuration updated successfully",
config = config
},
new UpdateTenantConfigurationResponse
{
Success = true,
Message = "Configuration updated successfully",
Config = config
});
}
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
}
@@ -0,0 +1,24 @@
using AutopilotMonitor.Functions.Services.Vulnerability;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <see cref="CpeMatchScorer.ProductKey"/> turns a finding's CPE ("base CPE:installed version")
/// into the product identity the exposure summary groups by.
/// </summary>
public class CpeProductKeyTests
{
[Theory]
[InlineData("cpe:2.3:a:7-zip:7-zip:24.08", "7-zip:7-zip")]
[InlineData("cpe:2.3:a:7-zip:7-zip:", "7-zip:7-zip")] // finding without a version
[InlineData("cpe:2.3:a:Microsoft:Teams:1.6.00.4472", "microsoft:teams")]
[InlineData("cpe:2.3:a:contoso:widget:*:*:*:*:*:*:*:*:1.2.3", "contoso:widget")] // custom mapping typed in full
[InlineData(@"cpe:2.3:a:microsoft:visual_c\+\+:14.0", @"microsoft:visual_c\+\+")] // CPE escapes stay part of the key
[InlineData("cpe:2.3:a:*:*:1.0", "")]
[InlineData("cpe:2.3:a", "")]
[InlineData("", "")]
[InlineData(null, "")]
public void ProductKey_IsLowerCaseVendorAndProduct(string? cpe, string expected)
=> Assert.Equal(expected, CpeMatchScorer.ProductKey(cpe));
}
@@ -74,8 +74,7 @@ public class CrossTenantAccessTests
[Theory]
[InlineData("GET", "/api/config/{0}")]
[InlineData("PUT", "/api/config/{0}")]
[InlineData("POST", "/api/config/{0}")]
[InlineData("PATCH", "/api/config/{0}/fields")]
[InlineData("GET", "/api/config/{0}/feature-flags")]
[InlineData("GET", "/api/tenants/{0}/admins")]
[InlineData("POST", "/api/tenants/{0}/admins")]
@@ -96,8 +95,7 @@ public class CrossTenantAccessTests
[Theory]
[InlineData("GET", "/api/config/{0}")]
[InlineData("PUT", "/api/config/{0}")]
[InlineData("POST", "/api/config/{0}")]
[InlineData("PATCH", "/api/config/{0}/fields")]
[InlineData("GET", "/api/config/{0}/feature-flags")]
[InlineData("GET", "/api/tenants/{0}/admins")]
[InlineData("POST", "/api/tenants/{0}/admins")]
@@ -118,7 +116,7 @@ public class CrossTenantAccessTests
[Theory]
[InlineData("GET", "/api/config/{0}")]
[InlineData("PUT", "/api/config/{0}")]
[InlineData("PATCH", "/api/config/{0}/fields")]
[InlineData("GET", "/api/tenants/{0}/admins")]
[InlineData("DELETE", "/api/tenants/{0}/offboard")]
public void CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string pathTemplate)
@@ -122,4 +122,22 @@ public class CveIndexEntityMergeTests
Assert.Single(TableStorageService.BuildCveIndexEntities(Tenant, Session, findings));
}
[Fact]
public void Product_IsTheCpeVendorAndProduct_ofTheWinningOccurrence()
{
var zip = Finding("7-Zip 24.08 (x64 edition)", "high", Vuln("CVE-1", 7.8, "HIGH"), Vuln("CVE-2", 3.3, "LOW"));
zip["cpeUri"] = "cpe:2.3:a:7-zip:7-zip:24.08";
var teams = Finding("Microsoft Teams", "high", Vuln("CVE-1", 8.8, "HIGH"));
teams["cpeUri"] = "cpe:2.3:a:microsoft:teams:1.6.00.4472";
var noCpe = Finding("Contoso Tool", "high", Vuln("CVE-3", 7.0, "HIGH"));
var entities = TableStorageService.BuildCveIndexEntities(Tenant, Session, new List<Dictionary<string, object>> { zip, teams, noCpe });
var cve1 = entities.Single(x => x.GetString("CveId") == "CVE-1");
Assert.Equal("Microsoft Teams", cve1.GetString("SoftwareName")); // name and product move together
Assert.Equal("microsoft:teams", cve1.GetString("Product"));
Assert.Equal("7-zip:7-zip", entities.Single(x => x.GetString("CveId") == "CVE-2").GetString("Product"));
Assert.Equal("", entities.Single(x => x.GetString("CveId") == "CVE-3").GetString("Product"));
}
}
@@ -584,17 +584,18 @@ public class DelegatedAdminServiceTests
}
[Fact]
public async Task RenameGroup_DoesNotInvalidateCache()
public async Task UpdateGroup_DoesNotInvalidateCache()
{
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>())).ReturnsAsync(true);
await svc.GetScopeAsync(Id(Upn));
await svc.RenameGroupAsync(GroupId1, "Renamed");
await svc.GetScopeAsync(Id(Upn)); // name-only change → scope stays cached
await svc.UpdateGroupAsync(GroupId1, "Renamed", "Service Desk");
await svc.GetScopeAsync(Id(Upn)); // name/label change → scope stays cached
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId1, "Renamed", "Service Desk"), Times.Once);
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
}
}
@@ -563,4 +563,62 @@ public class DelegationSelfServiceTests
Assert.False(group.Revocable);
Assert.Equal(2, group.Assignees.Count);
}
[Fact]
public async Task ListManagers_TwoLabelledOperatorGroups_AreDistinguishable_DirectGrantsStayNeutral()
{
const string serviceGroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
const string careGroupId = "1a2b3c4d5e6f70819293a4b5c6d7e8f9";
var h = Build();
h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List<string> { serviceGroupId, careGroupId });
h.Repo.Setup(x => x.GetTenantGroupAsync(serviceGroupId)).ReturnsAsync(new TenantGroup
{
GroupId = serviceGroupId, Name = "Internal managed service tenants", CustomerLabel = "Contoso Managed Service Team",
TenantIds = new List<string> { Customer },
});
h.Repo.Setup(x => x.GetTenantGroupAsync(careGroupId)).ReturnsAsync(new TenantGroup
{
GroupId = careGroupId, Name = "Internal care unit", CustomerLabel = " Contoso Customer Care Unit ",
TenantIds = new List<string> { Customer },
});
h.Repo.Setup(x => x.GetDelegatedAssigneesAsync(Customer)).ReturnsAsync(new List<DelegatedAdminEntry>
{
new() { Upn = "ops@vendor.example", TenantId = Customer, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, Status = Constants.DelegatedStatus.Active, GrantedAt = Now.AddDays(-10) },
});
var managers = await h.Svc.ListManagersAsync(Customer);
Assert.Equal("Contoso Managed Service Team", managers.Single(m => m.GroupId == serviceGroupId).Name);
Assert.Equal("Contoso Customer Care Unit", managers.Single(m => m.GroupId == careGroupId).Name);
Assert.All(managers.Where(m => m.GroupId != null), m =>
{
Assert.Equal(DelegationSelfService.SourceOperator, m.Source);
Assert.False(m.Revocable);
Assert.DoesNotContain("Internal", m.Name, StringComparison.Ordinal);
});
// A direct grant has no group, hence no label — it keeps the neutral name.
Assert.Equal(DelegationSelfService.OperatorLabel, managers.Single(m => m.GroupId == null).Name);
}
// ── Customer-facing group name ──────────────────────────────────────────────
[Theory]
[InlineData(null, "Platform support")]
[InlineData("", "Platform support")]
[InlineData(" ", "Platform support")]
[InlineData(" Contoso Service Desk ", "Contoso Service Desk")]
public void CustomerNameOf_OperatorGroup_LabelElseNeutral_NeverTheInternalName(string? label, string expected)
{
var group = new TenantGroup { GroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0", Name = "Internal name", CustomerLabel = label };
Assert.Equal(expected, DelegationSelfService.CustomerNameOf(group));
}
[Fact]
public void CustomerNameOf_OwnedGroup_IsItsName_LabelIgnored()
{
var group = new TenantGroup { GroupId = GroupId, Name = "partner.example", OwnerTenantId = Home, CustomerLabel = "Ignored" };
Assert.Equal("partner.example", DelegationSelfService.CustomerNameOf(group));
}
}
@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Minimal HTTP endpoint harness: a mocked <see cref="HttpRequestData"/> whose FunctionContext carries the
/// <see cref="RequestContext"/> the policy middleware would have resolved, serialized with the production
/// wire settings.
/// </summary>
internal static class EndpointHarness
{
public static (HttpRequestData Req, FunctionContext Context) Request(
string tenantId, string upn = "admin@contoso.com", string? jsonBody = null, string queryString = "",
IDictionary<string, string>? headers = null)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>
{
["RequestContext"] = new RequestContext
{
TenantId = tenantId,
TargetTenantId = tenantId,
UserPrincipalName = upn,
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
},
};
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
var headerCollection = new HttpHeadersCollection();
foreach (var (name, value) in headers ?? new Dictionary<string, string>())
headerCollection.Add(name, value);
req.SetupGet(r => r.Headers).Returns(headerCollection);
// Query parses the Url in the base class (a bare mock would hand back null).
req.SetupGet(r => r.Url).Returns(new Uri("https://localhost/api/test" + queryString));
req.SetupGet(r => r.Query).CallBase();
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(jsonBody ?? "")));
req.Setup(r => r.CreateResponse()).Returns(() => new FakeResponse(context.Object));
return (req.Object, context.Object);
}
/// <summary>The <c>code</c> of an error envelope; null when the body carries none.</summary>
public static string? ErrorCode(HttpResponseData response)
{
response.Body.Position = 0;
using var doc = JsonDocument.Parse(response.Body);
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
private sealed class FakeResponse : HttpResponseData
{
public FakeResponse(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
}
@@ -156,14 +156,17 @@ public class EndpointPolicyCatalogCompletenessTests
public void SameRoute_DifferentMethods_ResolveToDifferentPolicies()
{
var getConfig = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/tenant-1");
var putConfig = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1");
var patchConfig = EndpointAccessPolicyCatalog.FindPolicy("PATCH", "/api/config/tenant-1/fields");
Assert.NotNull(getConfig);
Assert.NotNull(putConfig);
Assert.NotNull(patchConfig);
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
// view; the handler redacts for every non-admin caller); the field PATCH stays Admin/GA only.
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, patchConfig.Policy);
// There is no full-model write of the tenant configuration (D-290).
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1"));
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/config/tenant-1"));
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
var postRules = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/gather");
@@ -1,4 +1,5 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models;
@@ -140,7 +141,7 @@ public class GenericWebhookTests
Assert.Empty(config.GetGenericWebhookHeaders());
}
// ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
// ── TenantConfigValidation.ValidateWebhookCustomHeaders ────
[Theory]
[InlineData(null)]
@@ -149,7 +150,7 @@ public class GenericWebhookTests
[InlineData("{\"Authorization\":\"Bearer abc\"}")]
public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
{
Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
Assert.Null(TenantConfigValidation.ValidateWebhookCustomHeaders(json));
}
[Theory]
@@ -160,7 +161,7 @@ public class GenericWebhookTests
[InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
{
var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
var error = TenantConfigValidation.ValidateWebhookCustomHeaders(json);
Assert.NotNull(error);
Assert.Contains(expectedFragment, error);
}

Some files were not shown because too many files have changed in this diff Show More