Compare commits

...

14 Commits

Author SHA1 Message Date
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
85 changed files with 2832 additions and 229 deletions
@@ -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);
}
}
@@ -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);
}
}
@@ -157,5 +157,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
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);
}
}
}
@@ -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);
}
@@ -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());
}
}
}
@@ -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()
{
@@ -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.
@@ -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; } } }
@@ -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
@@ -81,7 +81,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// multiply against the per-session budget.
///
/// <paramref name="sessionAgeHours"/> is the emergency break's measured session age
/// (<see cref="AgentErrorReport.SessionAgeHours"/>); null for every other error type.
/// (<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,
@@ -91,7 +93,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
int attempts = 1,
TimeSpan? perAttemptTimeout = null,
TimeSpan? retryDelay = null,
double? sessionAgeHours = 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
@@ -140,6 +143,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
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
@@ -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;
@@ -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); }
@@ -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
@@ -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,8 +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.
// The failed-registration record is read here, before the break deletes the
// session files.
onBreakFired: sessionAgeHours => EmergencyBreakReporter.TrySend(
agentConfig, Program.GetAgentVersion(), sessionAgeHours, logger)))
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,6 +78,62 @@ 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,
@@ -36,18 +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, double sessionAgeHours, 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);
// Invariant: the text lands in the backend's ops feed and timeline; a device locale
// would otherwise print "478,2h".
var roundedAgeHours = Math.Round(sessionAgeHours, 1);
var message = FormattableString.Invariant(
$"Agent absolute session-age emergency break fired at session age {roundedAgeHours:F1}h (cap {agentConfig.AbsoluteMaxSessionHours}h) — cleaning up and exiting.");
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
@@ -60,7 +62,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
attempts: SendAttempts,
perAttemptTimeout: PerAttemptTimeout,
retryDelay: RetryDelay,
sessionAgeHours: roundedAgeHours)
sessionAgeHours: roundedAgeHours,
priorRegistrationFailure: priorRegistrationFailure)
.Wait(OverallBudget);
}
catch (Exception ex)
@@ -69,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);
}
}
}
@@ -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,123 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tenant Hello wait (<see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/>): a changed value
/// must lie in 30..3600 s. Like the retention cap, only a CHANGED value is checked, so a value
/// stored before the rule existed never blocks an unrelated save or a backup revert (both
/// validate the whole model).
/// </summary>
public class HelloWaitTimeoutConfigTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
[Theory]
[InlineData(30)]
[InlineData(300)]
[InlineData(1800)]
[InlineData(3600)]
public void ValidateModel_accepts_the_supported_range(int seconds)
{
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = seconds;
Assert.Null(TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: false));
}
[Theory]
[InlineData(29)]
[InlineData(3601)]
[InlineData(0)]
[InlineData(-1)]
public void ValidateModel_rejects_a_changed_value_outside_the_range(int seconds)
{
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = seconds;
var error = TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: true);
Assert.Equal("Hello wait timeout must be between 30 and 3600 seconds.", error);
}
[Fact]
public void ValidateModel_lets_an_unchanged_out_of_range_value_through()
{
// Written through the API before the range was validated — an unrelated save (or a
// revert to a snapshot holding the same value) must not fail on it.
var existing = TenantConfiguration.CreateDefault(TenantId);
existing.HelloWaitTimeoutSeconds = 86400;
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = 86400;
candidate.ContactEmail = "it@example.com";
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
}
[Fact]
public void Default_is_the_built_in_five_minutes()
{
Assert.Equal(300, TenantConfiguration.CreateDefault(TenantId).HelloWaitTimeoutSeconds);
Assert.Equal(300, new CollectorConfiguration().HelloWaitTimeoutSeconds);
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(HelloWaitTimeout.DefaultSeconds));
}
[Fact]
public void Stored_old_default_stays_valid_and_keeps_the_built_in_window()
{
// Every tenant that ever saved the agent settings page has the old 30 s default stored.
var existing = TenantConfiguration.CreateDefault(TenantId);
existing.HelloWaitTimeoutSeconds = 30;
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = 30;
candidate.ContactEmail = "it@example.com";
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(30));
}
[Theory]
[InlineData(30, 300)]
[InlineData(300, 300)]
[InlineData(301, 301)]
[InlineData(3600, 3600)]
[InlineData(86400, 3600)]
[InlineData(0, 300)]
public void EffectiveSeconds_keeps_the_built_in_window_as_floor_and_caps_at_one_hour(int configured, int expected)
{
Assert.Equal(expected, HelloWaitTimeout.EffectiveSeconds(configured));
}
[Fact]
public void Row_without_the_column_reads_as_the_default()
{
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
entity.Remove("HelloWaitTimeoutSeconds");
Assert.Equal(300, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
}
[Fact]
public void Stored_value_is_read_verbatim()
{
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
entity["HelloWaitTimeoutSeconds"] = 30;
Assert.Equal(30, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
}
[Fact]
public void Configured_value_roundtrips_store_and_map()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.HelloWaitTimeoutSeconds = 2400;
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(2400, mapped.HelloWaitTimeoutSeconds);
}
}
@@ -330,6 +330,95 @@ public class ReportAgentErrorFunctionTests
It.Is<List<EnrollmentEvent>>(e => (double)e[0].Data["sessionAgeHours"] == 478.2)), Times.Once);
}
// A session that never registered leaves the backend nothing but this report; the agent's
// record of its failed starts is the only account of why.
[Fact]
public async Task Prior_registration_failure_lands_in_ops_details_even_without_a_row()
{
var h = new Harness();
var report = Report(Now);
report.PriorRegistrationFailure = ReportAgentErrorFunction.SanitizeRegistrationFailure(Failure());
await h.RunAsync(report, Read(null));
var details = Assert.Single(h.OpsEvents).Details;
Assert.Contains("\"priorRegistrationFailure\":{\"failedRuns\":3", details);
Assert.Contains("\"networkLinkUpAtEnd\":false", details);
Assert.Contains("\"attemptWindowSeconds\":1204.5", details);
Assert.Contains("\"configFetchOutcome\":\"UsedDefaults\"", details);
h.VerifyNothingWrittenIntoTheSession();
}
[Fact]
public async Task Prior_registration_failure_lands_in_the_timeline_data_of_a_registered_session()
{
var h = new Harness();
var report = Report(Now);
report.PriorRegistrationFailure = ReportAgentErrorFunction.SanitizeRegistrationFailure(Failure());
await h.RunAsync(report);
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(
It.Is<List<EnrollmentEvent>>(e =>
((Dictionary<string, object?>)e[0].Data["priorRegistrationFailure"])["failedRuns"]!.Equals(3))), Times.Once);
}
[Fact]
public async Task Agent_without_registration_record_leaves_it_null_and_out_of_the_timeline()
{
var h = new Harness();
await h.RunAsync(Report(Now));
Assert.Contains("\"priorRegistrationFailure\":null", Assert.Single(h.OpsEvents).Details);
h.SessionRepo.Verify(r => r.StoreEventsBatchAsync(
It.Is<List<EnrollmentEvent>>(e => !e[0].Data.ContainsKey("priorRegistrationFailure"))), Times.Once);
}
[Fact]
public void SanitizeRegistrationFailure_bounds_device_input()
{
var raw = Failure();
raw.LastError = new string('x', 5000);
raw.Outcome = new string('o', 500);
raw.ConfigFetchOutcome = new string('c', 500);
raw.AttemptWindowSeconds = -3;
var clean = ReportAgentErrorFunction.SanitizeRegistrationFailure(raw)!;
Assert.Equal(ReportAgentErrorFunction.MaxRegistrationErrorLength, clean.LastError!.Length);
Assert.Equal(ReportAgentErrorFunction.MaxRegistrationTokenLength, clean.Outcome.Length);
Assert.Equal(ReportAgentErrorFunction.MaxRegistrationTokenLength, clean.ConfigFetchOutcome!.Length);
Assert.Equal(0, clean.AttemptWindowSeconds);
Assert.Equal(3, clean.FailedRuns);
Assert.NotSame(raw, clean);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void SanitizeRegistrationFailure_drops_a_record_without_failed_runs(int failedRuns)
{
var raw = Failure();
raw.FailedRuns = failedRuns;
Assert.Null(ReportAgentErrorFunction.SanitizeRegistrationFailure(raw));
Assert.Null(ReportAgentErrorFunction.SanitizeRegistrationFailure(null));
}
private static RegistrationFailureSummary Failure() => new()
{
FailedRuns = 3,
FirstFailedAtUtc = Now.AddDays(-20),
LastFailedAtUtc = Now.AddDays(-19),
Outcome = "Failed",
LastError = "No such host is known.",
NetworkLinkUpAtStart = false,
NetworkLinkUpAtEnd = false,
AttemptWindowSeconds = 1204.5,
ConfigFetchOutcome = "UsedDefaults",
};
[Fact]
public async Task Agent_without_session_age_leaves_it_null_and_out_of_the_timeline()
{
@@ -373,4 +373,106 @@ public class RevokeEnforcementTests
proConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// --- Customer-visible audit rows name the group as the customer sees it, never internally ---
[Theory]
[InlineData(null, "Platform support")]
[InlineData("Contoso Service Desk", "Contoso Service Desk")]
public async Task UnassignCore_AuditNamesTheGroupByItsCustomerName(string? label, string expected)
{
var (fn, repo, audit, _) = BuildGroupFn();
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup
{
GroupId = GroupId,
Name = "Internal MSP customers north",
CustomerLabel = label,
TenantIds = new List<string> { TenantA },
AssigneeCount = 1,
});
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(UpnLower))
.ReturnsAsync(new List<TenantGroupAssignment> { new() { Upn = UpnLower, GroupId = GroupId } });
repo.Setup(r => r.UnassignGroupAsync(UpnLower, GroupId)).ReturnsAsync(true);
await fn.UnassignCoreAsync(GroupId, Upn, "ga@vendor.example");
audit.Verify(a => a.LogAuditEntryAsync(
TenantA, "DELETE", "DelegatedGroupAccess", UpnLower, "ga@vendor.example",
It.Is<Dictionary<string, string>?>(d => d != null && d["Group"] == expected && d["GroupId"] == GroupId)), Times.Once);
}
// --- Group update: name and/or customer label (PATCH only what changes) ---
[Theory]
[InlineData(null, null, "name or customerLabel is required")]
[InlineData(" ", null, "name must not be empty")]
[InlineData("Renamed", null, null)]
[InlineData(null, "", null)] // clears the label
[InlineData(null, "Contoso Service Desk", null)]
public void ValidateUpdateRequest_Shapes(string? name, string? label, string? expectedError)
=> Assert.Equal(expectedError,
TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { Name = name, CustomerLabel = label }));
[Fact]
public void ValidateUpdateRequest_LabelLongerThanCap_Rejected_TrimmedLengthCounts()
{
var atCap = " " + new string('x', DelegationSelfService.CustomerLabelMaxLength) + " ";
Assert.Null(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = atCap }));
var tooLong = new string('x', DelegationSelfService.CustomerLabelMaxLength + 1);
Assert.NotNull(TenantGroupManagementFunction.ValidateUpdateRequest(new UpdateTenantGroupRequest { CustomerLabel = tooLong }));
}
[Fact]
public async Task UpdateGroupCore_Label_OnOperatorGroup_WritesOnlyTheLabel_NoAudit()
{
var (fn, repo, audit, _) = BuildGroupFn();
repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(new TenantGroup { GroupId = GroupId, Name = "MSP Customers" });
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk")).ReturnsAsync(true);
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Contoso Service Desk" }, "ga@vendor.example");
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated, outcome);
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, null, "Contoso Service Desk"), Times.Once);
VerifyNoAudit(audit); // naming is not an access change
}
[Fact]
public async Task UpdateGroupCore_Label_OnOwnedGroup_Refused_NothingWritten()
{
var (fn, repo, _, _) = BuildGroupFn();
var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(OwnerTenant);
repo.Setup(r => r.GetTenantGroupAsync(ownedGroupId)).ReturnsAsync(new TenantGroup { GroupId = ownedGroupId, Name = "partner.example", OwnerTenantId = OwnerTenant });
var outcome = await fn.UpdateGroupCoreAsync(ownedGroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.LabelOnOwnedGroup, outcome);
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task UpdateGroupCore_Label_UnknownGroup_NotFound()
{
var (fn, repo, _, _) = BuildGroupFn(); // default: no group
var outcome = await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { CustomerLabel = "Anything" }, "ga@vendor.example");
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound, outcome);
repo.Verify(r => r.UpdateTenantGroupAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task UpdateGroupCore_RenameOnly_LeavesTheLabelAlone_MissIsNotFound()
{
var (fn, repo, _, _) = BuildGroupFn();
repo.Setup(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null)).ReturnsAsync(true);
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.Updated,
await fn.UpdateGroupCoreAsync(GroupId, new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId, "Renamed", null), Times.Once);
repo.Setup(r => r.UpdateTenantGroupAsync("tpl-missing", "Renamed", null)).ReturnsAsync(false);
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound,
await fn.UpdateGroupCoreAsync("tpl-missing", new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
}
}
@@ -498,7 +498,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return groupIds;
}
public async Task<bool> RenameTenantGroupAsync(string groupId, string name)
public async Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel)
{
if (string.IsNullOrWhiteSpace(groupId))
return false;
@@ -510,7 +510,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
var entity = result.Value;
if (entity == null) return false;
entity.Name = name?.Trim() ?? string.Empty;
if (name != null)
entity.Name = name.Trim();
if (customerLabel != null)
entity.CustomerLabel = customerLabel.Trim();
await _tenantGroupsTableClient.UpdateEntityAsync(entity, ETag.All);
return true;
}
@@ -1022,6 +1025,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
group.CreatedBy = entity.CreatedBy;
group.CreatedAt = entity.CreatedDate ?? default;
group.OwnerTenantId = string.IsNullOrEmpty(entity.OwnerTenantId) ? null : entity.OwnerTenantId;
group.CustomerLabel = string.IsNullOrEmpty(entity.CustomerLabel) ? null : entity.CustomerLabel;
return true;
}
@@ -949,7 +949,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
EnrollmentSummaryBrandingImageUrl = entity.GetString("EnrollmentSummaryBrandingImageUrl"),
EnrollmentSummaryLaunchRetrySeconds = entity.GetInt32("EnrollmentSummaryLaunchRetrySeconds"),
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? 30,
HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? HelloWaitTimeout.DefaultSeconds,
AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"),
SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true,
EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"),
@@ -23,8 +23,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
/// repository directly) so the delegated-scope cache is invalidated in lockstep.
///
/// Audit: access-affecting mutations are logged under the <b>affected managed tenant(s)</b>' trail (so the
/// customer sees "who can read my tenant" / "access removed"). Group create/rename carry no tenant context
/// and are not customer-visible access changes — they are logged operationally only (no AuditLogs partition).
/// customer sees "who can read my tenant" / "access removed"); the group appears there under its customer-facing
/// name (<see cref="DelegationSelfService.CustomerNameOf"/>), never its internal name. Group create/rename/label
/// carry no access change — they are logged operationally only (no AuditLogs partition).
/// </summary>
public class TenantGroupManagementFunction
{
@@ -100,8 +101,11 @@ public class TenantGroupManagementFunction
}
/// <summary>
/// PATCH /api/global/tenant-groups/{groupId} — rename a group. GlobalAdminOnly.
/// Body: { "name": "..." }. A rename has no scope effect.
/// PATCH /api/global/tenant-groups/{groupId} — rename a group and/or set the customer label of an operator
/// group. GlobalAdminOnly. Body: { "name"?: "...", "customerLabel"?: "..." } (only the fields to change;
/// an empty label clears it). Neither has a scope effect; the label only changes how the access is named to
/// the member tenants (<see cref="DelegationSelfService.CustomerNameOf"/>), so it is logged operationally
/// like a rename, not audited as an access change.
/// </summary>
[Function("UpdateTenantGroup")]
[Authorize]
@@ -114,14 +118,55 @@ public class TenantGroupManagementFunction
var read = await req.ReadAsync<UpdateTenantGroupRequest>();
if (read.Error != null) return read.Error;
var body = read.Value!;
if (string.IsNullOrWhiteSpace(body.Name))
return await Bad(req, "name is required");
var validationError = ValidateUpdateRequest(body);
if (validationError != null)
return await Bad(req, validationError);
if (!await _delegatedAdminService.RenameGroupAsync(groupId, body.Name))
return await NotFound(req);
_logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn);
return await UpdateGroupCoreAsync(groupId, body, currentUpn) switch
{
GroupUpdateOutcome.NotFound => await NotFound(req),
GroupUpdateOutcome.LabelOnOwnedGroup => await req.ConflictAsync(
"A self-service group has no customer label: its customers see the managing organization's name."),
_ => await req.OkAsync(new MessageResponse { Message = "Group updated" }),
};
}
return await req.OkAsync(new MessageResponse { Message = "Group updated" });
/// <summary>Outcome of <see cref="UpdateGroupCoreAsync"/>.</summary>
internal enum GroupUpdateOutcome { Updated, NotFound, LabelOnOwnedGroup }
/// <summary>Shape check of a PATCH body (400 message, or null when valid): at least one field, no blank name, a bounded label.</summary>
internal static string? ValidateUpdateRequest(UpdateTenantGroupRequest body)
{
if (body.Name == null && body.CustomerLabel == null)
return "name or customerLabel is required";
if (body.Name != null && string.IsNullOrWhiteSpace(body.Name))
return "name must not be empty";
if (body.CustomerLabel != null && body.CustomerLabel.Trim().Length > DelegationSelfService.CustomerLabelMaxLength)
return $"customerLabel must be at most {DelegationSelfService.CustomerLabelMaxLength} characters";
return null;
}
/// <summary>
/// Testable core of the group update (body already validated). A customer label is refused on an OWNED
/// (self-service) group — its customers see the managing tenant's name, so a label there would be dead data.
/// </summary>
internal async Task<GroupUpdateOutcome> UpdateGroupCoreAsync(string groupId, UpdateTenantGroupRequest body, string? currentUpn)
{
if (body.CustomerLabel != null)
{
var group = await _delegatedAdminService.GetGroupAsync(groupId);
if (group == null)
return GroupUpdateOutcome.NotFound;
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
return GroupUpdateOutcome.LabelOnOwnedGroup;
}
if (!await _delegatedAdminService.UpdateGroupAsync(groupId, body.Name, body.CustomerLabel))
return GroupUpdateOutcome.NotFound;
_logger.LogInformation("Tenant group updated: {GroupId} name='{Name}' customerLabel='{CustomerLabel}' by {By}",
groupId, body.Name, body.CustomerLabel, currentUpn);
return GroupUpdateOutcome.Updated;
}
/// <summary>DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly.</summary>
@@ -173,7 +218,7 @@ public class TenantGroupManagementFunction
await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn,
new Dictionary<string, string>
{
{ "Group", group.Name },
{ "Group", DelegationSelfService.CustomerNameOf(group) },
{ "GroupId", groupId },
{ "Reason", "group-deleted" },
{ "AssigneeCount", group.AssigneeCount.ToString() },
@@ -335,7 +380,7 @@ public class TenantGroupManagementFunction
await AuditPerTenantAsync(group.TenantIds, "CREATE", upn, currentUpn,
new Dictionary<string, string>
{
{ "Group", group.Name },
{ "Group", DelegationSelfService.CustomerNameOf(group) },
{ "GroupId", groupId },
{ "Role", role },
{ "Reason", "group-assigned" },
@@ -384,7 +429,7 @@ public class TenantGroupManagementFunction
await AuditPerTenantAsync(group.TenantIds, "DELETE", normalizedUpn, currentUpn,
new Dictionary<string, string>
{
{ "Group", group.Name },
{ "Group", DelegationSelfService.CustomerNameOf(group) },
{ "GroupId", groupId },
{ "Reason", "group-unassigned" },
});
@@ -167,6 +167,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return req.CreateResponse(HttpStatusCode.OK);
}
report.PriorRegistrationFailure = SanitizeRegistrationFailure(report.PriorRegistrationFailure);
var priorRegistration = report.PriorRegistrationFailure;
// Emit a structured log entry (captured by App Insights as a trace)
_logger.LogCritical(
"AgentEmergencyError [{ErrorType}] tenant={TenantId} session={SessionId} http={HttpStatusCode} seq={SequenceNumber} ver={AgentVersion}: {Message}",
@@ -191,6 +194,15 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["Message"] = report.Message ?? string.Empty,
["AgentTimestamp"] = report.Timestamp.ToString("O"),
["SessionAgeHours"] = report.SessionAgeHours?.ToString("0.0", CultureInfo.InvariantCulture) ?? string.Empty,
["PriorRegistrationFailedRuns"] = priorRegistration?.FailedRuns.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
["PriorRegistrationOutcome"] = priorRegistration?.Outcome ?? string.Empty,
["PriorRegistrationLastError"] = priorRegistration?.LastError ?? string.Empty,
["PriorRegistrationLinkUpAtStart"] = FormatNullableBool(priorRegistration?.NetworkLinkUpAtStart),
["PriorRegistrationLinkUpAtEnd"] = FormatNullableBool(priorRegistration?.NetworkLinkUpAtEnd),
["PriorRegistrationWindowSeconds"] = priorRegistration?.AttemptWindowSeconds.ToString("0.0", CultureInfo.InvariantCulture) ?? string.Empty,
["PriorRegistrationConfigFetch"] = priorRegistration?.ConfigFetchOutcome ?? string.Empty,
["PriorRegistrationFirstFailedAtUtc"] = priorRegistration?.FirstFailedAtUtc.ToString("O") ?? string.Empty,
["PriorRegistrationLastFailedAtUtc"] = priorRegistration?.LastFailedAtUtc.ToString("O") ?? string.Empty,
});
// One Sessions-row point-read serves the owner binding and the emergency-break verdict.
@@ -396,7 +408,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
await opsEventService.RecordAgentEmergencyBreakAsync(
tenantId, report.SessionId, report.AgentVersion, message,
verdict.Severity, verdict.SessionStatusAtBreak, verdict.LateCleanup, verdict.Context,
report.SessionAgeHours);
report.SessionAgeHours, report.PriorRegistrationFailure);
}
catch (Exception ex)
{
@@ -467,6 +479,39 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
? "Agent absolute session-age emergency break fired — agent cleaned up and exited"
: report.Message;
internal const int MaxRegistrationErrorLength = 256;
internal const int MaxRegistrationTokenLength = 32;
/// <summary>
/// Bounds the device-supplied failed-registration record before it reaches App Insights, the
/// ops feed or a timeline: strings are truncated, a record without a positive run count is
/// dropped, negative windows are clamped. Returns a copy; null in, null out.
/// </summary>
internal static RegistrationFailureSummary? SanitizeRegistrationFailure(RegistrationFailureSummary? source)
{
if (source == null || source.FailedRuns <= 0) return null;
return new RegistrationFailureSummary
{
FailedRuns = source.FailedRuns,
FirstFailedAtUtc = source.FirstFailedAtUtc,
LastFailedAtUtc = source.LastFailedAtUtc,
Outcome = Truncate(source.Outcome, MaxRegistrationTokenLength) ?? string.Empty,
LastError = Truncate(source.LastError, MaxRegistrationErrorLength),
NetworkLinkUpAtStart = source.NetworkLinkUpAtStart,
NetworkLinkUpAtEnd = source.NetworkLinkUpAtEnd,
AttemptWindowSeconds = source.AttemptWindowSeconds < 0 || double.IsNaN(source.AttemptWindowSeconds)
? 0
: source.AttemptWindowSeconds,
ConfigFetchOutcome = Truncate(source.ConfigFetchOutcome, MaxRegistrationTokenLength),
};
}
private static string? Truncate(string? value, int max) =>
value == null || value.Length <= max ? value : value.Substring(0, max);
private static string FormatNullableBool(bool? value) =>
value == null ? string.Empty : (value.Value ? "true" : "false");
/// <summary>
/// Builds the backend-materialized <c>agent_emergency_break</c> timeline event from the agent's
/// best-effort emergency report. Static + pure (analog to
@@ -497,6 +542,21 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
};
if (report.SessionAgeHours.HasValue)
data["sessionAgeHours"] = report.SessionAgeHours.Value;
if (report.PriorRegistrationFailure is { } prior)
{
data["priorRegistrationFailure"] = new Dictionary<string, object?>
{
["failedRuns"] = prior.FailedRuns,
["firstFailedAtUtc"] = prior.FirstFailedAtUtc.ToString("o"),
["lastFailedAtUtc"] = prior.LastFailedAtUtc.ToString("o"),
["outcome"] = prior.Outcome,
["lastError"] = prior.LastError,
["networkLinkUpAtStart"] = prior.NetworkLinkUpAtStart,
["networkLinkUpAtEnd"] = prior.NetworkLinkUpAtEnd,
["attemptWindowSeconds"] = prior.AttemptWindowSeconds,
["configFetchOutcome"] = prior.ConfigFetchOutcome,
};
}
return new EnrollmentEvent
{
@@ -161,6 +161,13 @@ namespace AutopilotMonitor.Functions.Helpers
&& (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit))
return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit}).";
// Only a CHANGED value is checked (same rule as the retention cap below): a stored value
// written before this rule existed must not block unrelated saves or backup reverts.
if (candidate.HelloWaitTimeoutSeconds != existing.HelloWaitTimeoutSeconds
&& (candidate.HelloWaitTimeoutSeconds < HelloWaitTimeout.MinSeconds
|| candidate.HelloWaitTimeoutSeconds > HelloWaitTimeout.MaxSeconds))
return $"Hello wait timeout must be between {HelloWaitTimeout.MinSeconds} and {HelloWaitTimeout.MaxSeconds} seconds.";
var contactEmailError = ValidateContactEmail(candidate.ContactEmail);
if (contactEmailError != null)
return $"Invalid contact email: {contactEmailError}";
@@ -268,9 +268,9 @@ public class DelegatedAdminService
public Task<string> CreateGroupAsync(string name, string createdBy)
=> _adminRepo.CreateTenantGroupAsync(name, createdBy);
/// <summary>Renames a group (name only — no scope effect, no invalidation needed).</summary>
public Task<bool> RenameGroupAsync(string groupId, string name)
=> _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name);
/// <summary>Renames a group and/or sets its customer label (null = unchanged) — no scope effect, no invalidation needed.</summary>
public Task<bool> UpdateGroupAsync(string groupId, string? name, string? customerLabel)
=> _adminRepo.UpdateTenantGroupAsync(NormalizeGroupId(groupId), name, customerLabel);
/// <summary>Creates a tenant-OWNED (self-service) group's meta row if missing — no scope effect (a fresh group has no members).</summary>
public Task EnsureOwnedGroupAsync(string groupId, string name, string ownerTenantId)
@@ -521,6 +521,9 @@ public class TenantGroupEntity : ITableEntity
/// <summary>Meta row only: the managing tenant that owns this self-service group (see <see cref="Constants.TenantGroupIds"/>); null on operator-created groups.</summary>
public string? OwnerTenantId { get; set; }
/// <summary>Meta row only: the name a managed customer sees for an operator-created group (see <see cref="DelegationSelfService.CustomerNameOf"/>); null or empty when none is set.</summary>
public string? CustomerLabel { get; set; }
}
/// <summary>
@@ -48,10 +48,13 @@ public class DelegationSelfService
public const string SourceSelfService = "self-service";
public const string SourceOperator = "operator";
/// <summary>
/// The one label a managed tenant sees for every operator-provisioned access (operator-created groups and
/// direct grants alike). Operator groups are internal organisation — their names never reach a customer.
/// The label a managed tenant sees for operator-provisioned access: direct grants, and operator-created groups
/// without a <see cref="TenantGroup.CustomerLabel"/>. Operator groups are internal organisation — their names
/// never reach a customer (D-267, D-300).
/// </summary>
public const string OperatorLabel = "Platform support";
/// <summary>Longest customer label a Global Admin may set on an operator group.</summary>
public const int CustomerLabelMaxLength = 80;
private const string AuditGroupAccess = "DelegatedGroupAccess";
private const string AuditInvitation = "DelegationInvitation";
private const string AuditManagedTenant = "DelegationManagedTenant";
@@ -382,11 +385,10 @@ public class DelegationSelfService
.Select(r => r.AcceptedAt)
.Max();
}
// An owned group carries its managing tenant's name; an operator group is internal and shows
// only the neutral label (its assignees stay visible — the customer must know who can read it).
// Its assignees stay visible either way — the customer must know who can read it.
result.Add(owner != null
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), group.Name, SourceSelfService, group.Assignees, since, Revocable: true)
: new TenantManagerView(group.GroupId, null, null, OperatorLabel, SourceOperator, group.Assignees, null, Revocable: false));
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), CustomerNameOf(group), SourceSelfService, group.Assignees, since, Revocable: true)
: new TenantManagerView(group.GroupId, null, null, CustomerNameOf(group), SourceOperator, group.Assignees, null, Revocable: false));
}
var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target))
@@ -401,6 +403,19 @@ public class DelegationSelfService
// ── Helpers ─────────────────────────────────────────────────────────────────
/// <summary>
/// The ONE place that decides how a group is named to a managed customer — the "Who can read this tenant"
/// view and every audit row written under a member tenant use it. An owned (self-service) group carries its
/// managing tenant's name; an operator group shows the customer label a Global Admin set for it, else the
/// neutral <see cref="OperatorLabel"/>. The operator group's own name never reaches a customer.
/// </summary>
public static string CustomerNameOf(TenantGroup group)
{
if (!string.IsNullOrWhiteSpace(group.OwnerTenantId))
return group.Name;
return string.IsNullOrWhiteSpace(group.CustomerLabel) ? OperatorLabel : group.CustomerLabel.Trim();
}
/// <summary>Decodes the ticket and loads the row. 400 for anything the link itself is wrong about.</summary>
private async Task<DelegationResult<DelegationInvitation>> LocateAsync(string token)
{
@@ -965,15 +965,39 @@ namespace AutopilotMonitor.Functions.Services
/// remaining Warnings named sessions that never registered; as Warnings both buried the real
/// ones. <paramref name="sessionAgeHours"/> is the agent-measured age (null for agents that
/// predate the field) — it separates a break just past the cap from a device that slept for
/// weeks, which the backend cannot tell from its own rows.
/// weeks, which the backend cannot tell from its own rows. <paramref name="priorRegistrationFailure"/>
/// is the agent's record of earlier starts that never registered (bounded by the caller;
/// null when none or for older agents) — for a session without a row the only account of why.
/// </summary>
public Task RecordAgentEmergencyBreakAsync(
string tenantId, string sessionId, string? agentVersion, string message,
string severity, string sessionStatusAtBreak, bool lateCleanup, string context, double? sessionAgeHours)
string severity, string sessionStatusAtBreak, bool lateCleanup, string context, double? sessionAgeHours,
RegistrationFailureSummary? priorRegistrationFailure = null)
=> WriteAsync(OpsEventCategory.Agent, OpsEventTypes.AgentEmergencyBreak, severity,
$"Agent emergency break on session {sessionId} (agent {agentVersion ?? "?"}, {context}): {message}",
tenantId, "System.EmergencyChannel",
new { sessionId, agentVersion, sessionStatusAtBreak, lateCleanup, sessionAgeHours });
new
{
sessionId, agentVersion, sessionStatusAtBreak, lateCleanup, sessionAgeHours,
priorRegistrationFailure = DescribeRegistrationFailure(priorRegistrationFailure),
});
// camelCase like the rest of the details; the class itself would serialize in PascalCase.
private static object? DescribeRegistrationFailure(RegistrationFailureSummary? failure) =>
failure == null
? null
: new
{
failedRuns = failure.FailedRuns,
firstFailedAtUtc = failure.FirstFailedAtUtc,
lastFailedAtUtc = failure.LastFailedAtUtc,
outcome = failure.Outcome,
lastError = failure.LastError,
networkLinkUpAtStart = failure.NetworkLinkUpAtStart,
networkLinkUpAtEnd = failure.NetworkLinkUpAtEnd,
attemptWindowSeconds = failure.AttemptWindowSeconds,
configFetchOutcome = failure.ConfigFetchOutcome,
};
/// <summary>
/// An agent reported that its RUNNING exe's SHA-256 differs from the hash the backend
+4 -4
View File
@@ -28,7 +28,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.4",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"vitest": "^4.1.11"
},
@@ -3752,9 +3752,9 @@
"optional": true
},
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -36,7 +36,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.4",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"vitest": "^4.1.11"
},
@@ -1043,7 +1043,7 @@ export interface CollectorConfiguration {
enableAgentSelfMetrics: boolean;
/** Interval in seconds for agent self-metrics snapshots. Default: 60 seconds */
agentSelfMetricsIntervalSeconds: number;
/** Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds */
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
helloWaitTimeoutSeconds: number;
/** Maximum agent lifetime in minutes. Safety net to prevent zombie agents. 0 = disabled (no lifetime limit). Default: 360 (6 hours). */
agentMaxLifetimeMinutes: number;
@@ -4810,7 +4810,7 @@ export interface TenantConfiguration {
enablePerformanceCollector: boolean;
/** Performance collector interval in seconds Default: 30 seconds */
performanceCollectorIntervalSeconds: number;
/** Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds */
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
helloWaitTimeoutSeconds: number;
/** Maximum consecutive authentication failures (401/403) before the agent shuts down. null = use default (5). 0 = disabled (retry forever). */
maxAuthFailures?: number | null;
@@ -5004,6 +5004,8 @@ export interface TenantGroup {
assignees: TenantGroupAssignment[];
/** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */
ownerTenantId?: string;
/** Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer. */
customerLabel?: string;
}
/** One UPN→group assignment. PK=UPN, RK=groupId in storage. */
@@ -5036,7 +5038,7 @@ export interface TenantManagerItem {
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
ownerTenantId?: string;
ownerDomain?: string;
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
/** The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer. */
name: string;
/** self-service | operator */
source: string;
@@ -5339,10 +5341,12 @@ export interface UpdateTenantAppHomingResponse {
probe: AppHomingProbeWire;
}
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
/** Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required. */
export interface UpdateTenantGroupRequest {
/** New display name. */
/** New display name (Global Admin UI only). */
name?: string | null;
/** The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups. */
customerLabel?: string | null;
}
/** Response of PUT sessions/{sessionId}/annotations/{lane} when both verdict and note were empty and the lane was cleared. */
@@ -12,7 +12,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
public static class DeadlineNames
{
/// <summary>Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s).</summary>
/// <summary>
/// Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s built in; a tenant
/// can extend it to 3600 s via <c>HelloWaitTimeoutSeconds</c>).
/// </summary>
public const string HelloSafety = "hello_safety";
/// <summary>Brief settle window after ESP exit before we emit completion.</summary>
@@ -104,6 +107,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>AadUserJoinedLate</c>: user presence indicator ("true" / "false").</summary>
public const string AadJoinedWithUser = "aadJoinedWithUser";
/// <summary>
/// On <c>WhiteGloveShellCoreSuccess</c>: "true" / "false" — whether the ESP registry
/// showed an AccountSetup subcategory past <c>notStarted</c> when the agent observed the
/// Shell-Core WhiteGlove_Success event. Missing (registry not read yet, or agents
/// predating the key) → the fact stays unset and the sealing classifier keeps the
/// IME-phase AccountSetup excluder.
/// </summary>
public const string AccountSetupProgress = "accountSetupProgress";
/// <summary>On <c>ImeUserSessionCompleted</c>: matched pattern id.</summary>
public const string ImePatternId = "patternId";
@@ -181,6 +193,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
public const string IsCloudPc = "isCloudPc";
/// <summary>
/// On <c>EnrollmentFactsObserved</c>: the tenant's Hello wait in seconds (remote config
/// <c>HelloWaitTimeoutSeconds</c>, stamped by the agent — NOT a registry fact). Only
/// stamped when it extends the built-in 300 s <see cref="DeadlineNames.HelloSafety"/>
/// window; missing → the built-in window.
/// </summary>
public const string HelloWaitTimeoutSeconds = "helloWaitTimeoutSeconds";
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
@@ -10,9 +10,6 @@ namespace AutopilotMonitor.DecisionCore.Engine
// Classic UserDriven-v1 enrollment handlers. Plan §2.5 partial-class layout.
public sealed partial class DecisionEngine
{
// Post-ESP Hello-safety grace period per plan §2.7.
private static readonly TimeSpan s_helloSafetyWindow = TimeSpan.FromSeconds(300);
// Grace window between both-prerequisites-resolved and Completed. Plan §5 Fix 6.
// Short enough that the user doesn't notice; long enough for the reducer's
// phase_transition(FinalizingSetup) + enrollment_complete effects to reach the
@@ -267,18 +264,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
: null);
}
// Replay-safety: floor the 300-s Hello-safety window at AgentBootUtc so a replayed
// EspExiting signal from CMTrace doesn't fire HelloSafety immediately at boot
// (which would mark Hello as Timeout the moment the agent reads the log tail).
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var helloSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
@@ -376,6 +362,89 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, AppendEffect(effects, waitingEffect));
}
/// <summary>
/// Post-ESP Hello-safety window (plan §2.7): the built-in 300 s, or the tenant's longer
/// <c>HelloWaitTimeoutSeconds</c> once the agent stamped it
/// (<see cref="EnrollmentScenarioObservations.HelloWaitTimeoutSeconds"/>), clamped to
/// <see cref="HelloWaitTimeout.MaxSeconds"/>.
/// </summary>
internal static TimeSpan HelloSafetyWindow(EnrollmentScenarioObservations observations) =>
TimeSpan.FromSeconds(HelloWaitTimeout.EffectiveSeconds(
observations.HelloWaitTimeoutSeconds?.Value ?? HelloWaitTimeout.BuiltInSeconds));
/// <summary>
/// The <see cref="DeadlineNames.HelloSafety"/> deadline every arm site schedules:
/// <see cref="HelloSafetyWindow"/> after the signal, floored at the agent boot
/// (<see cref="EffectiveDeadlineBase"/>) so a replayed EspExiting from CMTrace doesn't
/// fire HelloSafety immediately at boot (which would mark Hello as Timeout the moment
/// the agent reads the log tail).
/// </summary>
private static ActiveDeadline BuildHelloSafetyDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(HelloSafetyWindow(state.ScenarioObservations)),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
/// <summary>
/// The tenant window arrived after <see cref="DeadlineNames.HelloSafety"/> was armed —
/// necessarily with the built-in window, the fact being set-once. Moves the armed
/// deadline out by the difference so the tenant's window counts from the original
/// anchor, and returns the re-schedule effect (the live scheduler replaces a timer of
/// the same name). <c>null</c> when nothing changes: the fact was already known, the
/// window is not longer, no deadline is armed, or it had expired at this signal —
/// then its pending fire decides.
/// </summary>
private static DecisionEffect? ExtendArmedHelloSafetyToTenantWindow(
DecisionState before, DecisionStateBuilder builder, DecisionSignal signal)
{
if (before.ScenarioObservations.HelloWaitTimeoutSeconds != null)
return null;
var extension = HelloSafetyWindow(builder.ScenarioObservations) - HelloSafetyWindow(before.ScenarioObservations);
if (extension <= TimeSpan.Zero)
return null;
foreach (var armed in before.Deadlines)
{
if (armed.Name != DeadlineNames.HelloSafety)
continue;
if (armed.DueAtUtc <= signal.OccurredAtUtc)
return null;
var extended = new ActiveDeadline(
armed.Name, armed.DueAtUtc.Add(extension), armed.FiresSignalKind, armed.FiresPayload);
builder.AddDeadline(extended);
return new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: extended);
}
return null;
}
/// <summary>
/// <c>true</c> while a tenant-extended Hello wait is running: the tenant window is
/// recorded and a Hello resolution deadline (<see cref="DeadlineNames.HelloSafety"/>, or
/// the Device Preparation backstop that stands in for it) is armed and not yet due at
/// <paramref name="nowUtc"/>. The session is then waiting on a bounded window (up to
/// <see cref="HelloWaitTimeout.MaxSeconds"/>) that resolves it either way — not stalled.
/// Always <c>false</c> on the built-in window, so default tenants keep their stall
/// reporting unchanged. Read by the agent's stall probe.
/// </summary>
public static bool IsTenantHelloWaitPending(DecisionState? state, DateTime nowUtc)
{
if (state?.ScenarioObservations.HelloWaitTimeoutSeconds == null)
return false;
foreach (var d in state.Deadlines)
{
if ((d.Name == DeadlineNames.HelloSafety || d.Name == DeadlineNames.DevicePrepCompletion)
&& d.DueAtUtc > nowUtc)
return true;
}
return false;
}
/// <summary>
/// Session 8b8d611d fix (2026-05-20): emit a <see cref="DecisionEffectKind.CancelDeadline"/>
/// effect for <see cref="DeadlineNames.HelloSafety"/> when it is actually armed in
@@ -469,15 +538,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.CancelDeadline(DeadlineNames.FinalizingGrace);
}
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var helloSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
.AddDeadline(helloSafety);
@@ -524,7 +585,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// finished AccountSetup) OR <see cref="EnrollmentScenarioObservations.SkipUserEsp"/>
/// is <c>true</c> (no User-ESP page on this flow) — synthesise
/// <c>HelloOutcome="Skipped"</c> here and route directly through Finalizing.
/// This avoids waiting out the full 5-min HelloSafety window when the policy reader
/// This avoids waiting out the full HelloSafety window when the policy reader
/// already told us no Hello wizard is expected. Belt-and-suspenders alongside the
/// EspExiting → HelloSafety reducer path: the path that fires first wins.
/// </para>
@@ -842,15 +903,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
// AgentBootUtc for replay safety) — mirror of the deferred-promote tail in
// HandleAccountSetupProvisioningCompleteV1. A real HelloResolved then completes
// normally; a no-show resolves via HelloSafety's synthetic outcome.
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var helloSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
@@ -20,13 +20,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Resolution window from real-user desktop arrival on a Device Preparation session.
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
/// through a configured Hello wizard, and the tracker's own Hello completion
/// timers normally resolve long before this fires — it is the last net, not the
/// expected path.
/// not the built-in <c>HelloSafety</c> window (300 s): the user may legitimately still
/// be walking through a configured Hello wizard, and with the built-in budget the
/// tracker's own Hello completion timer normally resolves long before this fires — it
/// is the last net, not the expected path. A tenant-extended Hello wait lengthens it
/// (see <see cref="BuildDevicePrepCompletionDeadline"/>).
/// </summary>
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
private static TimeSpan DevicePrepCompletionWindow(EnrollmentScenarioObservations observations)
{
var helloWindow = HelloSafetyWindow(observations);
return helloWindow > s_devicePrepCompletionWindow ? helloWindow : s_devicePrepCompletionWindow;
}
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
{
@@ -39,12 +46,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc). Never
/// shorter than the tenant's Hello window (<see cref="HelloSafetyWindow"/>, up to 60 min),
/// so a tenant that allows Hello an hour gets that hour on WDP too. With a window of 30 min
/// or more it may resolve before the tracker's timer (it counts from the desktop, the
/// tracker from the wizard start). A tenant window that arrives after the backstop was
/// armed (agent update mid-session) does not move it.
/// </summary>
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
name: DeadlineNames.DevicePrepCompletion,
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(DevicePrepCompletionWindow(state.ScenarioObservations)),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
@@ -770,15 +770,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
&& state.HelloPolicyEnabled?.Value == false
&& state.HelloWizardStartedUtc != null)
{
var wizardHelloSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var wizardHelloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: wizardHelloSafetyDueAt,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var wizardHelloSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
@@ -886,15 +878,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
&& state.HelloPolicyEnabled == null
&& state.HelloWizardStartedUtc == null)
{
var neverObservedSafetyDueAt = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var neverObservedSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: neverObservedSafetyDueAt,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var neverObservedSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
@@ -393,6 +393,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
private DecisionStep HandleHelloSafetyDeadlineFired(DecisionState state, DecisionSignal signal)
{
// Stale fire of a replaced incarnation (same shape as the RealmJoin guard): the armed
// hello_safety is due LATER than the due time this fire belongs to — e.g. the tenant
// window arrived and moved the deadline out while the old timer's fire was already
// queued. Deciding on it would time Hello out at the replaced, shorter window.
foreach (var armed in state.Deadlines)
{
if (armed.Name == DeadlineNames.HelloSafety && armed.DueAtUtc > DeadlineDueAtUtc(signal))
{
var bookkept = BumpStepBookkeeping(state, signal);
return new DecisionStep(
bookkept,
BuildDeadEndTransition(
state: state,
signal: signal,
nextStepIndex: bookkept.StepIndex,
trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}",
deadEndReason: "hello_safety_stale_superseded_by_rearm"),
Array.Empty<DecisionEffect>());
}
}
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
@@ -523,6 +544,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// the handler delegates the merge logic and only carries out the standard
/// step bookkeeping + transition.
/// </para>
/// <para>
/// The one effect it can emit: a <see cref="DecisionEffectKind.ScheduleDeadline"/> that
/// moves an already armed <see cref="DeadlineNames.HelloSafety"/> out to the tenant's
/// Hello window when that fact arrives late (see
/// <see cref="ExtendArmedHelloSafetyToTenantWindow"/>).
/// </para>
/// </summary>
private DecisionStep HandleEnrollmentFactsObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -559,6 +586,22 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithCloudPc(isCloudPc, signal.SessionSignalOrdinal);
}
// Tenant Hello wait (remote config, not a registry fact) — widens the hello_safety
// window. Set-once, and only a value that actually extends the built-in window counts
// (the agent never stamps anything else). This signal is posted right after agent
// start, but a backfilled ESP exit can arm hello_safety first; that deadline is moved
// out to the tenant window.
DecisionEffect? helloSafetyExtendEffect = null;
if (signal.Payload != null
&& signal.Payload.TryGetValue(SignalPayloadKeys.HelloWaitTimeoutSeconds, out var rawHelloWait)
&& int.TryParse(rawHelloWait, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var helloWaitSeconds)
&& helloWaitSeconds > HelloWaitTimeout.BuiltInSeconds)
{
builder.ScenarioObservations = builder.ScenarioObservations
.WithHelloWaitTimeoutSeconds(helloWaitSeconds, signal.SessionSignalOrdinal);
helloSafetyExtendEffect = ExtendArmedHelloSafetyToTenantWindow(state, builder, signal);
}
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
@@ -567,7 +610,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.EnrollmentFactsObserved));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
return new DecisionStep(
newState,
transition,
helloSafetyExtendEffect != null ? new[] { helloSafetyExtendEffect } : Array.Empty<DecisionEffect>());
}
/// <summary>
@@ -959,15 +1005,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
// the replay-safety guarantee.
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var helloSafety = BuildHelloSafetyDeadline(state, signal);
builder = builder
.WithStage(SessionStage.AwaitingHello)
@@ -53,6 +53,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.ScenarioObservations = builder.ScenarioObservations.WithShellCoreWhiteGloveSuccessSeen(signal.SessionSignalOrdinal);
// Recorded before the inline classifier runs: it decides whether the AccountSetup
// excluder applies (see BuildWhiteGloveSealingSnapshot).
if (signal.Payload != null
&& signal.Payload.TryGetValue(SignalPayloadKeys.AccountSetupProgress, out var rawProgress)
&& bool.TryParse(rawProgress, out var accountSetupProgress))
{
builder.ScenarioObservations = builder.ScenarioObservations
.WithAccountSetupProgressAtWhiteGloveSuccess(accountSetupProgress, signal.SessionSignalOrdinal);
}
// Option 3 fast-path: inline classifier evaluation on the strong WG signal.
var afterObservation = builder.Build();
var inlineSnapshot = BuildWhiteGloveSealingSnapshot(afterObservation);
@@ -362,6 +372,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <see cref="WhiteGloveSealingSnapshot.ComputeInputHash"/> is therefore byte-stable
/// across the refactor, which preserves the anti-loop semantics.
/// </para>
/// <para>
/// <c>hasAccountSetupActivity</c> needs the IME AccountSetup phase AND no explicit
/// "registry shows no AccountSetup progress" on the WhiteGlove_Success signal. IME
/// reports AccountSetup from the device session as soon as the device apps are done, so
/// on pre-provisioning with the user ESP enabled that line routinely precedes the
/// technician's success page. The conjunction keeps the excluder a subset of the old
/// one: a verdict can only rise, and a missing payload (older agent) scores exactly as
/// before.
/// </para>
/// </summary>
internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) =>
new WhiteGloveSealingSnapshot(
@@ -370,7 +389,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
aadJoinedWithUser: state.ScenarioObservations.AadUserJoinWithUserObserved?.Value == true,
desktopArrived: state.DesktopArrivedUtc != null,
helloResolved: state.HelloResolvedUtc != null,
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null,
hasAccountSetupActivity: state.AccountSetupEnteredUtc != null
&& state.ScenarioObservations.AccountSetupProgressAtWhiteGloveSuccess?.Value != false,
isDeviceOnlyDeploymentHypothesis:
state.ClassifierOutcomes.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong &&
state.ClassifierOutcomes.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly,
@@ -144,6 +144,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
seen.Add("whiteglove_sealing_pattern");
evidence["whiteGloveSealingPattern"] = OrdinalEvidence(obs.WhiteGloveSealingPatternSeen.SourceSignalOrdinal);
}
// Registry AccountSetup progress stamped on the WhiteGlove_Success signal — the gate of
// the sealing classifier's AccountSetup excluder, so a Part-1 audit trail shows why
// an IME AccountSetup phase did or did not count.
if (obs.AccountSetupProgressAtWhiteGloveSuccess != null)
{
seen.Add(obs.AccountSetupProgressAtWhiteGloveSuccess.Value
? "whiteglove_account_setup_progress"
: "whiteglove_account_setup_untouched");
evidence["accountSetupProgressAtWhiteGloveSuccess"] = new Dictionary<string, object>(capacity: 2, StringComparer.Ordinal)
{
["ordinal"] = obs.AccountSetupProgressAtWhiteGloveSuccess.SourceSignalOrdinal,
["progress"] = obs.AccountSetupProgressAtWhiteGloveSuccess.Value,
};
}
if (obs.AadUserJoinWithUserObserved != null)
{
seen.Add(obs.AadUserJoinWithUserObserved.Value
@@ -163,6 +177,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
seen.Add("cloud_pc_marker");
evidence["cloudPcMarker"] = OrdinalEvidence(obs.CloudPc.SourceSignalOrdinal);
}
// Tenant-extended Hello window — surfaced so a terminal audit trail explains a Hello
// timeout (or AwaitingHello dwell) longer than the built-in 5 minutes.
if (obs.HelloWaitTimeoutSeconds != null)
{
seen.Add("hello_wait_extended");
evidence["helloWaitExtended"] = new Dictionary<string, object>(capacity: 2, StringComparer.Ordinal)
{
["ordinal"] = obs.HelloWaitTimeoutSeconds.SourceSignalOrdinal,
["seconds"] = obs.HelloWaitTimeoutSeconds.Value,
};
}
return new SignalCensusResult(seen, timestamps, evidence);
}
@@ -33,6 +33,17 @@ namespace AutopilotMonitor.DecisionCore.State
/// <summary>True once <see cref="Signals.DecisionSignalKind.WhiteGloveSealingPatternDetected"/> has fired.</summary>
public SignalFact<bool>? WhiteGloveSealingPatternSeen { get; init; }
/// <summary>
/// ESP-registry AccountSetup progress as stamped on the
/// <see cref="Signals.DecisionSignalKind.WhiteGloveShellCoreSuccess"/> payload:
/// <c>true</c> = an AccountSetup subcategory had left <c>notStarted</c>, <c>false</c> =
/// none had. <c>null</c> = not reported (registry not read yet, or an agent predating the
/// payload key) and must be read as "unknown", never as "no progress". Gates the sealing classifier's
/// AccountSetup excluder: IME logs "EspPhase: AccountSetup" from the device session as
/// soon as the device apps are done, so only an explicit <c>false</c> here lifts it.
/// </summary>
public SignalFact<bool>? AccountSetupProgressAtWhiteGloveSuccess { get; init; }
/// <summary>
/// Payload-carrying observation from <see cref="Signals.DecisionSignalKind.AadUserJoinedLate"/>.
/// <c>true</c> = late AADJ observed with a user-side principal (hard-excluder for
@@ -103,6 +114,15 @@ namespace AutopilotMonitor.DecisionCore.State
/// </summary>
public SignalFact<bool>? CloudPc { get; init; }
/// <summary>
/// Tenant-config fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
/// (payload <c>helloWaitTimeoutSeconds</c>, stamped by the agent from
/// <c>RemoteConfig.HelloWaitTimeoutSeconds</c> only when it exceeds the built-in 300 s):
/// the <c>hello_safety</c> window the engine arms instead of the built-in one.
/// <c>null</c> = built-in window. Set-once.
/// </summary>
public SignalFact<int>? HelloWaitTimeoutSeconds { get; init; }
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
ShellCoreWhiteGloveSuccessSeen != null
? this
@@ -113,6 +133,11 @@ namespace AutopilotMonitor.DecisionCore.State
? this
: this with { WhiteGloveSealingPatternSeen = new SignalFact<bool>(true, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithAccountSetupProgressAtWhiteGloveSuccess(bool value, long sourceSignalOrdinal) =>
AccountSetupProgressAtWhiteGloveSuccess != null
? this
: this with { AccountSetupProgressAtWhiteGloveSuccess = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithAadUserJoinWithUserObserved(bool value, long sourceSignalOrdinal) =>
AadUserJoinWithUserObserved != null
? this
@@ -152,5 +177,10 @@ namespace AutopilotMonitor.DecisionCore.State
CloudPc != null
? this
: this with { CloudPc = new SignalFact<bool>(value, sourceSignalOrdinal) };
public EnrollmentScenarioObservations WithHelloWaitTimeoutSeconds(int value, long sourceSignalOrdinal) =>
HelloWaitTimeoutSeconds != null
? this
: this with { HelloWaitTimeoutSeconds = new SignalFact<int>(value, sourceSignalOrdinal) };
}
}
@@ -51,8 +51,11 @@ namespace AutopilotMonitor.Shared.DataAccess
// --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") ---
/// <summary>Creates a group (meta row) and returns the generated groupId.</summary>
Task<string> CreateTenantGroupAsync(string name, string createdBy);
/// <summary>Renames a group (meta row). Returns false if the group does not exist.</summary>
Task<bool> RenameTenantGroupAsync(string groupId, string name);
/// <summary>
/// Updates a group's meta row: <paramref name="name"/> and <paramref name="customerLabel"/> are each left
/// unchanged when null; an empty label clears it. Returns false if the group does not exist.
/// </summary>
Task<bool> UpdateTenantGroupAsync(string groupId, string? name, string? customerLabel);
/// <summary>Deletes a group: all rows in its partition (meta + membership) AND every UPN
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
Task<bool> DeleteTenantGroupAsync(string groupId);
@@ -153,6 +156,8 @@ namespace AutopilotMonitor.Shared.DataAccess
public List<TenantGroupAssignment> Assignees { get; set; } = new();
/// <summary>The managing tenant that owns this self-service group (<c>msp-{tenantId}</c>); null for operator-created groups.</summary>
public string? OwnerTenantId { get; set; }
/// <summary>Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer.</summary>
public string? CustomerLabel { get; set; }
}
/// <summary>
@@ -588,11 +588,13 @@ namespace AutopilotMonitor.Shared.Models
public string Name { get; set; } = string.Empty;
}
/// <summary>Body of PATCH global/tenant-groups/{groupId} — a rename.</summary>
/// <summary>Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required.</summary>
public class UpdateTenantGroupRequest : IApiRequest
{
/// <summary>New display name.</summary>
/// <summary>New display name (Global Admin UI only).</summary>
public string? Name { get; set; }
/// <summary>The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups.</summary>
public string? CustomerLabel { get; set; }
}
/// <summary>Body of POST global/tenant-groups/{groupId}/tenants.</summary>
@@ -317,10 +317,11 @@ namespace AutopilotMonitor.Shared.Models
public int AgentSelfMetricsIntervalSeconds { get; set; } = 60;
/// <summary>
/// Seconds to wait for the Windows Hello wizard after ESP exit.
/// Default: 30 seconds
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
/// Default: 300 seconds
/// </summary>
public int HelloWaitTimeoutSeconds { get; set; } = 30;
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
/// <summary>
/// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
@@ -0,0 +1,36 @@
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Bounds of the tenant setting <see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/> —
/// how long after the Enrollment Status Page closes the agent waits for Windows Hello for
/// Business (wizard start plus setup) before it records Hello as timed out. Single source for
/// the backend validation, the agent's Hello tracker and the decision engine's
/// <c>hello_safety</c> deadline; the web settings input mirrors the configurable bounds.
/// </summary>
public static class HelloWaitTimeout
{
/// <summary>Smallest configurable value.</summary>
public const int MinSeconds = 30;
/// <summary>Largest configurable value (1 hour) — also the ceiling of the effective wait.</summary>
public const int MaxSeconds = 3600;
/// <summary>
/// The engine's built-in Hello window (5 minutes). Configured values up to it keep it —
/// the old 30 s default is still stored for many tenants — only larger values extend it.
/// </summary>
public const int BuiltInSeconds = 300;
/// <summary>Default for tenants that never changed the setting: the built-in window.</summary>
public const int DefaultSeconds = BuiltInSeconds;
/// <summary>
/// The wait a configured value stands for: clamped into
/// [<see cref="BuiltInSeconds"/>, <see cref="MaxSeconds"/>].
/// </summary>
public static int EffectiveSeconds(int configuredSeconds) =>
configuredSeconds < BuiltInSeconds ? BuiltInSeconds
: configuredSeconds > MaxSeconds ? MaxSeconds
: configuredSeconds;
}
}
@@ -396,10 +396,11 @@ namespace AutopilotMonitor.Shared.Models
public int PerformanceCollectorIntervalSeconds { get; set; } = 30;
/// <summary>
/// Seconds to wait for the Windows Hello wizard after ESP exit
/// Default: 30 seconds
/// Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up
/// to 300 keep the built-in 300 s Hello window; larger values extend it.
/// Default: 300 seconds
/// </summary>
public int HelloWaitTimeoutSeconds { get; set; } = 30;
public int HelloWaitTimeoutSeconds { get; set; } = HelloWaitTimeout.DefaultSeconds;
// ===== AGENT AUTH CIRCUIT BREAKER =====
@@ -163,7 +163,7 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants.</summary>
public string? OwnerTenantId { get; set; }
public string? OwnerDomain { get; set; }
/// <summary>The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer.</summary>
/// <summary>The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer.</summary>
public string Name { get; set; } = default!;
/// <summary>self-service | operator</summary>
public string Source { get; set; } = default!;
@@ -90,6 +90,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public double? SessionAgeHours { get; set; }
/// <summary>
/// Why earlier process starts of this session never registered
/// (<see cref="AgentErrorType.SessionAgeEmergencyBreak"/> only). Null when every start
/// registered, for other error types, and for agents that predate the field.
/// </summary>
public RegistrationFailureSummary? PriorRegistrationFailure { get; set; }
/// <summary>
/// Agent version string for diagnosing version-specific issues.
/// </summary>
@@ -0,0 +1,44 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// What the agent recorded locally each time a process start ended without a registered
/// session (register-session failed after all retries, the agent exited). Carried on the
/// emergency-break report (<see cref="AgentErrorReport.PriorRegistrationFailure"/>): for a
/// session that never registered it is the only account of why that ever reaches the
/// backend. Cleared by the next successful registration.
/// </summary>
public class RegistrationFailureSummary
{
/// <summary>Process starts whose registration failed since the last success.</summary>
public int FailedRuns { get; set; }
/// <summary>UTC time the first of these runs gave up.</summary>
public DateTime FirstFailedAtUtc { get; set; }
/// <summary>UTC time the latest of these runs gave up.</summary>
public DateTime LastFailedAtUtc { get; set; }
/// <summary>Registration outcome of the latest failed run (e.g. <c>Failed</c>, <c>AuthFailed</c>).</summary>
public string Outcome { get; set; } = default!;
/// <summary>Last error message of the latest failed run, truncated by the agent.</summary>
public string? LastError { get; set; }
/// <summary>Network link up (any interface) when the latest failed run started registering.</summary>
public bool? NetworkLinkUpAtStart { get; set; }
/// <summary>Network link up when the latest failed run gave up.</summary>
public bool? NetworkLinkUpAtEnd { get; set; }
/// <summary>
/// Wall-clock seconds the latest failed run spent registering (link wait plus all attempts).
/// Far above the retry schedule means the device slept or was suspended meanwhile.
/// </summary>
public double AttemptWindowSeconds { get; set; }
/// <summary>Outcome of the remote-config fetch in the latest failed run (e.g. <c>Succeeded</c>, <c>UsedDefaults</c>).</summary>
public string? ConfigFetchOutcome { get; set; }
}
}
@@ -57,7 +57,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
{
question: "Is Autopilot Monitor free and open source?",
answer:
"Yes. The Community plan is free and stays free, includes the complete current feature set, and is meant for production fleets, not just labs. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. A commercial Pro plan is coming for organizations that need reliability commitments, priority support, longer data retention, and delegated administration across customer tenants.",
"Yes. The Community plan is free, stays free, and is meant for production fleets, not just labs: live monitoring, the full rules engine including custom rules, fleet analytics, notifications, diagnostics, and AI integration through MCP within usage limits, with 90-day data retention. The source code is on GitHub: the agent under the MIT license, the backend, portal, and MCP server under AGPL-3.0. The optional Pro plan, sold through Microsoft Marketplace and Cleverbridge, adds 365-day retention, higher API and AI usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; a tenant administrator can try it free for 30 days.",
},
{
question: "How is the agent deployed, and does it stay on the device?",
@@ -23,6 +23,9 @@ interface SlotPromptState extends SlotLimitError {
retry: { key: string; url: string; method: string; body: string | undefined; ok: string };
}
/** What a managed customer sees for an operator group without a customer label (DelegationSelfService.OperatorLabel). */
const OPERATOR_LABEL = "Platform support";
const ROLE_LABELS: Record<string, string> = {
DelegatedReader: "Reader (read-only)",
DelegatedAdmin: "Admin (read + write)",
@@ -160,6 +163,23 @@ export function SectionTenantGroups() {
[mutate],
);
const handleCustomerLabel = useCallback(
async (t: TenantGroup) => {
const input = prompt(`Name managed customers see for this group (empty = "${OPERATOR_LABEL}"):`, t.customerLabel ?? "");
if (input === null) return;
const customerLabel = input.trim();
if (customerLabel === (t.customerLabel ?? "")) return;
await mutate(
`label:${t.groupId}`,
api.tenantGroups.update(t.groupId),
"PATCH",
jsonBody<UpdateTenantGroupRequest>({ customerLabel }),
customerLabel ? `Customers now see "${customerLabel}".` : `Customers now see "${OPERATOR_LABEL}".`,
);
},
[mutate],
);
const handleDeleteGroup = useCallback(
async (t: TenantGroup) => {
const extra = t.assigneeCount > 0 ? ` ${t.assigneeCount} assignee(s) will lose this access.` : "";
@@ -363,6 +383,11 @@ export function SectionTenantGroups() {
Owned by {domainOf(t.ownerTenantId) || t.ownerTenantId}
</span>
)}
{!t.ownerTenantId && (
<p className="text-xs text-gray-500">
Customers see:{" "}<span className="text-gray-700">{t.customerLabel || OPERATOR_LABEL}</span>
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!t.ownerTenantId && (
@@ -373,6 +398,13 @@ export function SectionTenantGroups() {
>
Rename
</button>
<button
onClick={() => handleCustomerLabel(t)}
disabled={busyKey === `label:${t.groupId}`}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
Customer label
</button>
<button
onClick={() => handleDeleteGroup(t)}
className="px-3 py-1 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
@@ -516,7 +548,8 @@ export function SectionTenantGroups() {
<p className="text-xs text-gray-500">
Reader is read-only (secrets redacted). Only onboarded tenants appear in the dropdown. Changes take
effect on the assignee&rsquo;s next request.
effect on the assignee&rsquo;s next request. Customers never see a group&rsquo;s name: they see its customer
label, or &ldquo;{OPERATOR_LABEL}&rdquo; when none is set.
</p>
</div>
</div>
+2 -2
View File
@@ -18,7 +18,7 @@ import AppInsightsInit from "../components/AppInsightsInit";
import ChunkReloadRecovery from "../components/ChunkReloadRecovery";
import { HostRoutingGuard } from "../components/HostRoutingGuard";
import { LegacyPathRedirect } from "../components/LegacyPathRedirect";
import { API_BASE_URL, DOCS_URL, SITE_URL } from "@/utils/config";
import { API_BASE_URL, DOCS_URL, SITE_URL, sitePageUrl } from "@/utils/config";
import { AUTH_HINT_INLINE_SCRIPT } from "@/lib/authHint";
const inter = Inter({ subsets: ["latin"] });
@@ -145,7 +145,7 @@ const jsonLd = {
name: "Community plan",
price: "0",
priceCurrency: "EUR",
url: `${SITE_URL}/plans`,
url: sitePageUrl("/plans"),
},
author: { "@id": PERSON_ID },
creator: { "@id": PERSON_ID },
@@ -12,6 +12,7 @@ import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthF
import { trackEvent } from "@/lib/appInsights";
import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck";
import { primaryClientId } from "@/lib/authApp";
import { canManageBootstrapSessions } from "@/lib/tenantScope";
type ValidationTrigger = "autopilot" | "corporate" | "device-preparation";
@@ -27,6 +28,7 @@ import { parseSasExpiry } from "./components/DiagnosticsSection";
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChannel, LEGACY_CHANNEL_ID } from "./types";
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
import { HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS } from "./lib/helloWaitTimeout";
import { changedTenantConfigFields, patchTenantConfigFields } from "@/lib/tenantConfigSave";
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
@@ -428,7 +430,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Collector settings
const [enablePerformanceCollector, setEnablePerformanceCollector] = useState(true);
const [performanceCollectorInterval, setPerformanceCollectorInterval] = useState(30);
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(30);
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
const [autopilotConsentInProgress, setAutopilotConsentInProgress] = useState(false);
// Agent behavior
@@ -591,7 +593,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
setPerformanceCollectorInterval(data.performanceCollectorIntervalSeconds ?? 30);
setHelloWaitTimeoutSeconds(data.helloWaitTimeoutSeconds ?? 30);
setHelloWaitTimeoutSeconds(data.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
setSelfDestructOnComplete(data.selfDestructOnComplete ?? true);
setKeepLogFile(data.keepLogFile ?? false);
setRebootOnComplete(data.rebootOnComplete ?? false);
@@ -721,16 +723,19 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}
}, [tenantId, getAccessToken]);
// The list route is the BootstrapManagerOrGA tier: any other role only ever received a 403.
const canManageBootstrap = canManageBootstrapSessions(user);
useEffect(() => {
// Effective availability (mirrors backend IsBootstrapEnabled): Pro plan includes bootstrap;
// the per-tenant GA flag is the additive Community enable.
const bootstrapAvailable = editionInfo.edition === "pro" || config?.bootstrapTokenEnabled;
if (!tenantId || !bootstrapAvailable) return;
if (!tenantId || !bootstrapAvailable || !canManageBootstrap) return;
const run = async () => {
await fetchBootstrapSessions();
};
void run();
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, fetchBootstrapSessions]);
}, [tenantId, editionInfo.edition, config?.bootstrapTokenEnabled, canManageBootstrap, fetchBootstrapSessions]);
// Built-in sections + global diagnostics paths are read-only context for every role and
// live in useDiagnosticsPathsCatalog (GET /api/diagnostics/paths), not in this provider.
@@ -1223,7 +1228,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (!config) return;
setEnablePerformanceCollector(config.enablePerformanceCollector ?? true);
setPerformanceCollectorInterval(config.performanceCollectorIntervalSeconds ?? 30);
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? 30);
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
setSelfDestructOnComplete(config.selfDestructOnComplete ?? true);
setKeepLogFile(config.keepLogFile ?? false);
setRebootOnComplete(config.rebootOnComplete ?? false);
@@ -4,6 +4,12 @@ import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../lib/helloWaitTimeout";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
@@ -429,16 +435,16 @@ export default function AgentSettingsSection({
<span className="text-sm text-gray-600">Timeout:</span>
<input
type="number"
min={30}
max={300}
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
value={helloWaitTimeoutSeconds}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || 30)}
onBlur={() => setHelloWaitTimeoutSeconds(Math.max(30, Math.min(300, helloWaitTimeoutSeconds)))}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
</div>
</div>
</div>
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../helloWaitTimeout";
describe("Hello wait timeout bounds", () => {
it("mirror the server's HelloWaitTimeout range", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../../../../../Shared/AutopilotMonitor.Shared/Models/Config/HelloWaitTimeout.cs"),
"utf8",
);
expect(source).toMatch(new RegExp(`MinSeconds = ${HELLO_WAIT_TIMEOUT_MIN_SECONDS};`));
expect(source).toMatch(new RegExp(`MaxSeconds = ${HELLO_WAIT_TIMEOUT_MAX_SECONDS};`));
});
it("default matches the C# model default carried by the shared manifest", () => {
expect(SHARED_MANIFEST.tenantConfiguration.defaults.helloWaitTimeoutSeconds).toBe(HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
});
it("keeps values inside the range", () => {
expect(clampHelloWaitTimeoutSeconds(30)).toBe(30);
expect(clampHelloWaitTimeoutSeconds(300)).toBe(300);
expect(clampHelloWaitTimeoutSeconds(3600)).toBe(3600);
});
it("clamps values outside the range", () => {
expect(clampHelloWaitTimeoutSeconds(0)).toBe(30);
expect(clampHelloWaitTimeoutSeconds(29)).toBe(30);
expect(clampHelloWaitTimeoutSeconds(86400)).toBe(3600);
});
it("resolves a non-numeric value to the default and rounds fractions", () => {
expect(clampHelloWaitTimeoutSeconds(Number.NaN)).toBe(300);
expect(clampHelloWaitTimeoutSeconds(1800.6)).toBe(1801);
});
});
@@ -0,0 +1,15 @@
/**
* Bounds of the tenant Hello wait (`helloWaitTimeoutSeconds`). Mirrors `HelloWaitTimeout` in
* AutopilotMonitor.Shared — the server rejects a changed value outside min/max (the test next to
* this file pins all three). Values up to 300 s keep the agent's built-in 5-minute Hello window;
* larger values extend it.
*/
export const HELLO_WAIT_TIMEOUT_MIN_SECONDS = 30;
export const HELLO_WAIT_TIMEOUT_MAX_SECONDS = 3600;
export const HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS = 300;
/** Clamps into the configurable range; a non-numeric value resolves to the default. */
export function clampHelloWaitTimeoutSeconds(value: number): number {
if (!Number.isFinite(value)) return HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS;
return Math.min(HELLO_WAIT_TIMEOUT_MAX_SECONDS, Math.max(HELLO_WAIT_TIMEOUT_MIN_SECONDS, Math.round(value)));
}
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useTenantConfig } from "../../TenantConfigContext";
import { isProViaMsp, missingContactProfileParts, trialDaysLeft } from "@/lib/edition";
import { PlanCards } from "@/components/plans/PlanCards";
import { SITE_URL } from "@/utils/config";
import { sitePageUrl } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -108,7 +108,7 @@ export function SectionPlan() {
<p className="mt-3 text-xs text-gray-600">
Ready to buy? Pro is sold through Microsoft Marketplace and Cleverbridge —{" "}
<a
href={`${SITE_URL}/buy`}
href={sitePageUrl("/buy")}
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 hover:underline dark:text-purple-300"
+9 -11
View File
@@ -3,9 +3,7 @@ export const dynamic = "force-static";
import type { MetadataRoute } from "next";
import { PAGE_LASTMOD } from "@/utils/page-lastmod.generated";
import { SITE_URL } from "@/utils/config";
const BASE_URL = SITE_URL;
import { sitePageUrl } from "@/utils/config";
// Build-time only (static export): a URL missing from PAGE_LASTMOD means
// scripts/generate-lastmod.js drifted from this list -- fail the build rather
@@ -24,49 +22,49 @@ function lastmod(urlPath: string): Date {
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${BASE_URL}/`,
url: sitePageUrl("/"),
lastModified: lastmod("/"),
changeFrequency: "monthly",
priority: 1,
},
{
url: `${BASE_URL}/about`,
url: sitePageUrl("/about"),
lastModified: lastmod("/about"),
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${BASE_URL}/get-started`,
url: sitePageUrl("/get-started"),
lastModified: lastmod("/get-started"),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/plans`,
url: sitePageUrl("/plans"),
lastModified: lastmod("/plans"),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/buy`,
url: sitePageUrl("/buy"),
lastModified: lastmod("/buy"),
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${BASE_URL}/help`,
url: sitePageUrl("/help"),
lastModified: lastmod("/help"),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${BASE_URL}/privacy`,
url: sitePageUrl("/privacy"),
lastModified: lastmod("/privacy"),
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/terms`,
url: sitePageUrl("/terms"),
lastModified: lastmod("/terms"),
changeFrequency: "yearly",
priority: 0.3,
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import { trackEvent } from '@/lib/appInsights';
import type { SignalRMessageName } from '@/lib/signalrMessages';
import { createGroupRegistry } from '@/lib/signalrGroupRegistry';
import { refusedJoinStatus } from '@/lib/signalrGroupAccess';
import { useLatest } from '@/hooks/useLatest';
import { useAuth } from './AuthContext';
import { ApiError, fetchOk, jsonBody, type GetAccessToken } from "@/lib/apiClient";
@@ -67,7 +68,15 @@ function getTransportName(conn: HubConnection): string {
}
export function SignalRProvider({ children }: { children: React.ReactNode }) {
const { getAccessToken, isAuthenticated } = useAuth();
const { getAccessToken, isAuthenticated, user } = useAuth();
// The caller's standing for the join gate (lib/signalrGroupAccess), held as primitives so joinGroup
// keeps its identity until the standing itself changes — in practice once, when auth/me resolves,
// in the same commit that delivers the tenant id the consumers join with.
const callerTenantId = user?.tenantId ?? '';
const callerRole = user?.role ?? null;
const callerIsTenantAdmin = user?.isTenantAdmin ?? false;
const callerIsGlobalAdmin = user?.isGlobalAdmin ?? false;
const callerIsGlobalReader = user?.isGlobalReader ?? false;
const [connection, setConnection] = useState<HubConnection | null>(null);
const [connectionState, setConnectionState] = useState<HubConnectionState>(HubConnectionState.Disconnected);
const connectionRef = useRef<HubConnection | null>(null);
@@ -353,6 +362,24 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
return;
}
// A join the backend refuses by rule for this caller is not sent. The consumer learns it exactly
// as from the server's answer, and nothing is held for the reconnect rejoin.
const caller = callerTenantId
? {
tenantId: callerTenantId,
role: callerRole,
isTenantAdmin: callerIsTenantAdmin,
isGlobalAdmin: callerIsGlobalAdmin,
isGlobalReader: callerIsGlobalReader,
}
: null;
const refused = refusedJoinStatus(groupName, caller, options?.serialNumber);
if (refused !== null) {
console.info(`[SignalR] Not joining group ${groupName}: the server refuses it for this user (status ${refused})`);
options?.onDenied?.(refused);
return;
}
// Add to Set immediately to prevent race conditions with multiple simultaneous calls
joinedGroupsRef.current.add(groupName);
if (options?.serialNumber) {
@@ -395,7 +422,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
joinSerialsRef.current.delete(groupName);
syncJoinedGroups();
}
}, [groupRegistry, getToken, syncJoinedGroups]);
}, [groupRegistry, getToken, syncJoinedGroups, callerTenantId, callerRole, callerIsTenantAdmin, callerIsGlobalAdmin, callerIsGlobalReader]);
const leaveGroup = useCallback((groupName: string): Promise<void> => {
// Only this consumer's reference is dropped here; the hub leave follows from the registry
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { groupTenantId, refusedJoinStatus, type GroupJoinCaller } from "../signalrGroupAccess";
/**
* The client gate must refuse exactly what the backend refuses by rule (SignalRGroupHelper +
* SignalRAddToGroupFunction) for the caller's HOME tenant, and leave every cross-tenant join
* to the backend's admission.
*/
const HOME = "11111111-1111-1111-1111-111111111111";
const OTHER = "22222222-2222-2222-2222-222222222222";
const SESSION = "33333333-3333-3333-3333-333333333333";
const broadcast = (tid: string) => `tenant-${tid}`;
const notifyMember = (tid: string) => `tenant-${tid}-notify-member`;
const notifyAdmin = (tid: string) => `tenant-${tid}-notify-admin`;
const session = (tid: string) => `session-${tid}-${SESSION}`;
function caller(overrides: Partial<GroupJoinCaller> = {}): GroupJoinCaller {
return {
tenantId: HOME,
isTenantAdmin: false,
isGlobalAdmin: false,
isGlobalReader: false,
isDelegated: false,
role: null,
...overrides,
};
}
describe("groupTenantId", () => {
it("parses every tenant-scoped group format", () => {
expect(groupTenantId(broadcast(HOME))).toBe(HOME);
expect(groupTenantId(notifyMember(HOME))).toBe(HOME);
expect(groupTenantId(notifyAdmin(HOME))).toBe(HOME);
expect(groupTenantId(session(HOME))).toBe(HOME);
});
it("is null or empty for names the backend cannot attribute", () => {
expect(groupTenantId("tenant-")).toBe("");
expect(groupTenantId("session-abc")).toBeNull();
expect(groupTenantId("global-admins")).toBeNull();
expect(groupTenantId("something-else")).toBeNull();
});
});
describe("refusedJoinStatus", () => {
it("answers 400 for a name the backend cannot parse, whoever asks", () => {
// The dashboard used to join "tenant-" before auth/me delivered the tenant id.
for (const c of [null, caller(), caller({ isGlobalAdmin: true })]) {
expect(refusedJoinStatus("tenant-", c)).toBe(400);
expect(refusedJoinStatus("session-abc", c)).toBe(400);
expect(refusedJoinStatus("unknown-group", c)).toBe(400);
}
});
it("leaves well-formed joins to the backend while the caller is unknown", () => {
for (const g of [broadcast(HOME), notifyMember(HOME), notifyAdmin(HOME), session(HOME), "global-admins"]) {
expect(refusedJoinStatus(g, null)).toBeNull();
}
});
it("refuses a roleless caller its home tenant's member groups and serial-less session groups", () => {
const roleless = caller();
expect(refusedJoinStatus(broadcast(HOME), roleless)).toBe(403);
expect(refusedJoinStatus(notifyMember(HOME), roleless)).toBe(403);
expect(refusedJoinStatus(notifyAdmin(HOME), roleless)).toBe(403);
expect(refusedJoinStatus(session(HOME), roleless)).toBe(403);
});
it("lets a roleless caller join a session group with a serial number (Progress Portal)", () => {
expect(refusedJoinStatus(session(HOME), caller(), "SERIAL-1")).toBeNull();
});
it("refuses a delegated admin without own role its HOME tenant's broadcast group", () => {
// hasTenantReadScope would admit them; the backend does not.
expect(refusedJoinStatus(broadcast(HOME), caller({ isDelegated: true }))).toBe(403);
});
it("never decides a cross-tenant join (platform or delegated admission is the backend's)", () => {
for (const c of [caller(), caller({ isDelegated: true }), caller({ role: "Viewer" })]) {
expect(refusedJoinStatus(broadcast(OTHER), c)).toBeNull();
expect(refusedJoinStatus(notifyMember(OTHER), c)).toBeNull();
expect(refusedJoinStatus(notifyAdmin(OTHER), c)).toBeNull();
expect(refusedJoinStatus(session(OTHER), c)).toBeNull();
}
});
it.each(["Operator", "Viewer"] as const)("admits a %s to every home group except the admin notify group", (role) => {
const member = caller({ role });
expect(refusedJoinStatus(broadcast(HOME), member)).toBeNull();
expect(refusedJoinStatus(notifyMember(HOME), member)).toBeNull();
expect(refusedJoinStatus(session(HOME), member)).toBeNull();
expect(refusedJoinStatus(notifyAdmin(HOME), member)).toBe(403);
});
it("admits the tenant Admin and a Global Admin to every home group", () => {
for (const c of [caller({ isTenantAdmin: true, role: "Admin" }), caller({ isGlobalAdmin: true })]) {
for (const g of [broadcast(HOME), notifyMember(HOME), notifyAdmin(HOME), session(HOME)]) {
expect(refusedJoinStatus(g, c)).toBeNull();
}
}
});
it("keeps the admin notify group closed to a Global Reader without the Admin role", () => {
const reader = caller({ isGlobalReader: true });
expect(refusedJoinStatus(broadcast(HOME), reader)).toBeNull();
expect(refusedJoinStatus(notifyMember(HOME), reader)).toBeNull();
expect(refusedJoinStatus(notifyAdmin(HOME), reader)).toBe(403);
});
it("matches the home tenant case-insensitively", () => {
expect(refusedJoinStatus(broadcast(HOME.toUpperCase()), caller())).toBe(403);
});
it("opens global-admins to platform scope only", () => {
expect(refusedJoinStatus("global-admins", caller())).toBe(403);
expect(refusedJoinStatus("global-admins", caller({ isTenantAdmin: true, role: "Admin" }))).toBe(403);
expect(refusedJoinStatus("global-admins", caller({ isGlobalReader: true }))).toBeNull();
expect(refusedJoinStatus("global-admins", caller({ isGlobalAdmin: true }))).toBeNull();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
canManageBootstrapSessions,
hasOwnTenantOrPlatformRole,
hasTenantReadScope,
type TenantScopeUser,
@@ -81,3 +82,28 @@ describe("hasTenantReadScope", () => {
).toBe(true);
});
});
describe("canManageBootstrapSessions (mirror of BootstrapManagerOrGA)", () => {
it("is false for null/undefined and a roleless user", () => {
expect(canManageBootstrapSessions(null)).toBe(false);
expect(canManageBootstrapSessions(undefined)).toBe(false);
expect(canManageBootstrapSessions(user())).toBe(false);
});
it("is true for a Global Admin and the tenant Admin", () => {
expect(canManageBootstrapSessions(user({ isGlobalAdmin: true }))).toBe(true);
expect(canManageBootstrapSessions(user({ isTenantAdmin: true }))).toBe(true);
expect(canManageBootstrapSessions(user({ role: "Admin" }))).toBe(true);
});
it("admits an Operator only with the bootstrap grant", () => {
expect(canManageBootstrapSessions(user({ role: "Operator" }))).toBe(false);
expect(canManageBootstrapSessions(user({ role: "Operator", canManageBootstrapTokens: true }))).toBe(true);
});
it("refuses a Viewer even with the flag, a Global Reader and a delegated admin (write tier)", () => {
expect(canManageBootstrapSessions(user({ role: "Viewer", canManageBootstrapTokens: true }))).toBe(false);
expect(canManageBootstrapSessions(user({ isGlobalReader: true }))).toBe(false);
expect(canManageBootstrapSessions(user({ isDelegated: true }))).toBe(false);
});
});
@@ -0,0 +1,66 @@
import { hasOwnTenantOrPlatformRole, type TenantScopeUser } from "./tenantScope";
/**
* Client mirror of the SignalR join rules the backend decides from the caller's HOME-tenant
* standing alone (SignalRGroupHelper + SignalRAddToGroupFunction). A join the backend refuses by
* rule is not sent: it only ever produced a 4xx row and an api_request_failed event per page visit.
*
* Cross-tenant joins are deliberately NOT decided here, exactly as the backend helpers leave them
* to the cross-tenant admission (platform scope, or a delegated grant over the group's tenant).
* "Member" is hasOwnTenantOrPlatformRole, not hasTenantReadScope: a delegated admin without an
* own-tenant role reads the managed tenants but is refused its home tenant's member groups.
*/
/** The caller's standing as auth/me reports it; `tenantId` is the HOME tenant. */
export interface GroupJoinCaller extends TenantScopeUser {
tenantId: string;
}
const NOTIFY_MEMBER_SUFFIX = "-notify-member";
const NOTIFY_ADMIN_SUFFIX = "-notify-admin";
const GLOBAL_ADMINS_GROUP = "global-admins";
/** The tenant a group belongs to, parsed like SignalRGroupHelper.ExtractTenantIdFromGroupName. */
export function groupTenantId(groupName: string): string | null {
if (groupName.startsWith("session-")) {
// "session-{tenantId}-{sessionId}": the tenant GUID is dash-parts 1..5.
const parts = groupName.split("-");
return parts.length >= 7 ? parts.slice(1, 6).join("-") : null;
}
if (groupName.startsWith("tenant-")) {
const rest = groupName.slice("tenant-".length);
if (rest.endsWith(NOTIFY_ADMIN_SUFFIX)) return rest.slice(0, -NOTIFY_ADMIN_SUFFIX.length);
if (rest.endsWith(NOTIFY_MEMBER_SUFFIX)) return rest.slice(0, -NOTIFY_MEMBER_SUFFIX.length);
return rest;
}
return null;
}
/**
* The status the backend answers this join with by rule — 400 for a group name it cannot parse,
* 403 for a refusal — or null when the join may be admitted. Without a caller (auth/me not
* resolved yet) only the name check applies; the backend decides the rest.
*/
export function refusedJoinStatus(
groupName: string,
caller: GroupJoinCaller | null,
serialNumber?: string,
): 400 | 403 | null {
if (groupName === GLOBAL_ADMINS_GROUP) {
return caller && !caller.isGlobalAdmin && !caller.isGlobalReader ? 403 : null;
}
const tenantId = groupTenantId(groupName);
if (!tenantId) return 400;
if (!caller?.tenantId || tenantId.toLowerCase() !== caller.tenantId.toLowerCase()) return null;
if (groupName.endsWith(NOTIFY_ADMIN_SUFFIX)) {
return caller.isTenantAdmin || caller.isGlobalAdmin ? null : 403;
}
if (hasOwnTenantOrPlatformRole(caller)) return null;
// No own-tenant role and no platform scope: the broadcast and member notification groups are
// refused; a session group needs the device's serial number as knowledge proof.
if (groupName.startsWith("session-")) return serialNumber ? null : 403;
return 403;
}
@@ -11,6 +11,7 @@ export interface TenantScopeUser {
isGlobalReader?: boolean;
isDelegated?: boolean;
role?: string | null;
canManageBootstrapTokens?: boolean;
}
/** The three assignable tenant roles (mirrors Constants.TenantRoles in the backend). */
@@ -50,3 +51,21 @@ export function hasTenantReadScope(
if (!user) return false;
return hasOwnTenantOrPlatformRole(user) || !!user.isDelegated;
}
/**
* True when the user may list and manage their OWN tenant's bootstrap sessions — the client
* mirror of the backend's BootstrapManagerOrGA tier: Global Admin, the tenant Admin, or an
* Operator granted "manage bootstrap tokens". It is a write tier, so neither the read-only
* Global Reader nor a delegated grant is admitted.
*/
export function canManageBootstrapSessions(
user: TenantScopeUser | null | undefined
): boolean {
if (!user) return false;
return !!(
user.isGlobalAdmin ||
user.isTenantAdmin ||
user.role === "Admin" ||
(user.role === "Operator" && user.canManageBootstrapTokens)
);
}
@@ -2,17 +2,18 @@
> Free, open-source real-time monitoring and troubleshooting for Windows Autopilot enrollments managed through Microsoft Intune. A lightweight, temporary agent streams enrollment events to a web portal, analyze rules flag failure patterns automatically, and a Model Context Protocol (MCP) server lets AI assistants query the data. Created and maintained by Oliver Kieselbach (Microsoft MVP); the hosted service is operated by glueckkanja AG and runs in Azure Germany West Central.
Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and covers the full current feature set; a Pro plan with reliability commitments, priority support, longer retention, and delegated administration across customer tenants is coming.
Autopilot Monitor is for IT administrators, helpdesk engineers, and managed service providers who deploy Windows devices with Intune and Autopilot. The Community plan is free and stays free: live monitoring, the full rules engine, fleet analytics, and AI integration (MCP) within usage limits. The optional Pro plan adds 365-day instead of 90-day retention, higher usage limits, delegated administration across customer tenants, OOBE bootstrap sessions, reliability commitments, and priority support; it is sold through Microsoft Marketplace and Cleverbridge, and a tenant administrator can try it free for 30 days.
## Portal
- [Home](https://www.autopilotmonitor.com/): product overview
- [About](https://www.autopilotmonitor.com/about): what it is, how it works, common questions
- [Get started](https://www.autopilotmonitor.com/get-started): first steps for a new tenant
- [Plans](https://www.autopilotmonitor.com/plans): Community vs. Pro
- [Help](https://www.autopilotmonitor.com/help): support channels
- [Privacy Policy](https://www.autopilotmonitor.com/privacy)
- [Terms of Use](https://www.autopilotmonitor.com/terms)
- [About](https://www.autopilotmonitor.com/about/): what it is, how it works, common questions
- [Get started](https://www.autopilotmonitor.com/get-started/): first steps for a new tenant
- [Plans](https://www.autopilotmonitor.com/plans/): Community vs. Pro
- [Get Pro](https://www.autopilotmonitor.com/buy/): purchase options and the free 30-day trial
- [Help](https://www.autopilotmonitor.com/help/): support channels
- [Privacy Policy](https://www.autopilotmonitor.com/privacy/)
- [Terms of Use](https://www.autopilotmonitor.com/terms/)
## Documentation
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import nextConfig from "../../next.config";
import sitemap from "../../app/sitemap";
import { SITE_URL, sitePageUrl } from "../config";
/**
* Public pages are served at /page/ only: the static export writes
* page/index.html (next.config trailingSlash: true), SWA's trailingSlash "auto"
* 301-redirects /page to /page/, and Next emits /page/ as the canonical URL.
* Page URLs handed to crawlers outside Next metadata (sitemap, llms.txt) must
* use that form, or each one is a redirect to a different canonical URL
* (Search Console: "Page with redirect").
*/
const WEB_ROOT = join(__dirname, "..", "..");
/** Served form: SITE_URL origin, path "/" or "/segment/.../". */
function expectServedForm(url: string): void {
const parsed = new URL(url);
expect(parsed.origin, url).toBe(SITE_URL);
expect(parsed.pathname, url).toMatch(/^\/(?:[^/]+\/)*$/);
}
describe("public page URLs use the served /page/ form", () => {
it("the export and SWA serve public pages at /page/", () => {
expect(nextConfig.trailingSlash).toBe(true);
const swa = JSON.parse(readFileSync(join(WEB_ROOT, "staticwebapp.config.json"), "utf-8"));
expect(swa.trailingSlash).toBe("auto");
});
it("sitePageUrl appends the trailing slash exactly once", () => {
expect(sitePageUrl("/")).toBe(`${SITE_URL}/`);
expect(sitePageUrl("/plans")).toBe(`${SITE_URL}/plans/`);
expect(sitePageUrl("/plans/")).toBe(`${SITE_URL}/plans/`);
});
it("sitemap lists every page in the served form", () => {
const urls = sitemap().map((entry) => entry.url);
expect(urls.length).toBeGreaterThan(0);
for (const url of urls) expectServedForm(url);
});
it("llms.txt links site pages in the served form", () => {
const llms = readFileSync(join(WEB_ROOT, "public", "llms.txt"), "utf-8");
const links = [...llms.matchAll(/\]\(([^)\s]+)\)/g)]
.map((match) => match[1])
.filter((url) => new URL(url).origin === SITE_URL);
expect(links.length).toBeGreaterThan(0);
for (const url of links) expectServedForm(url);
});
});
@@ -29,6 +29,19 @@ export const DOCS_URL = "https://docs.autopilotmonitor.com";
/** Public marketing/product website (also feeds metadataBase/sitemap/robots). */
export const SITE_URL = "https://www.autopilotmonitor.com";
/**
* Absolute URL of a public page in the /page/ form the site serves: the static
* export writes page/index.html (next.config trailingSlash: true) and SWA
* 301-redirects /page to /page/. Next applies that form to metadata URLs
* (canonical, og:url) itself; every page URL built outside metadata (sitemap,
* JSON-LD, absolute links) goes through here, so crawlers get the canonical URL
* instead of a redirect. sitePageUrl.guard.test.ts pins the coupling.
*/
export function sitePageUrl(path: `/${string}`): string {
const slashed = path.endsWith("/") ? path : `${path}/`;
return `${SITE_URL}${slashed}`;
}
/** Customer portal (deep links rendered into generated bootstrap scripts). */
export const PORTAL_URL = "https://portal.autopilotmonitor.com";
@@ -273,7 +273,7 @@ export const SHARED_MANIFEST = {
"maxNdjsonPayloadSizeMB": 5,
"enablePerformanceCollector": true,
"performanceCollectorIntervalSeconds": 30,
"helloWaitTimeoutSeconds": 30,
"helloWaitTimeoutSeconds": 300,
"maxAuthFailures": null,
"authFailureTimeoutMinutes": null,
"agentMaxLifetimeMinutes": null,
@@ -383,7 +383,7 @@ export const SHARED_MANIFEST = {
"desktopDetectorNoCandidateTimeoutMinutes": 10,
"enableAgentSelfMetrics": true,
"agentSelfMetricsIntervalSeconds": 60,
"helloWaitTimeoutSeconds": 30,
"helloWaitTimeoutSeconds": 300,
"agentMaxLifetimeMinutes": 360,
"enableDeliveryOptimizationCollector": true,
"deliveryOptimizationIntervalSeconds": 3,
@@ -269,7 +269,7 @@
"maxNdjsonPayloadSizeMB": 5,
"enablePerformanceCollector": true,
"performanceCollectorIntervalSeconds": 30,
"helloWaitTimeoutSeconds": 30,
"helloWaitTimeoutSeconds": 300,
"maxAuthFailures": null,
"authFailureTimeoutMinutes": null,
"agentMaxLifetimeMinutes": null,
@@ -379,7 +379,7 @@
"desktopDetectorNoCandidateTimeoutMinutes": 10,
"enableAgentSelfMetrics": true,
"agentSelfMetricsIntervalSeconds": 60,
"helloWaitTimeoutSeconds": 30,
"helloWaitTimeoutSeconds": 300,
"agentMaxLifetimeMinutes": 360,
"enableDeliveryOptimizationCollector": true,
"deliveryOptimizationIntervalSeconds": 3,
@@ -5485,7 +5485,7 @@
"kind": "primitive",
"name": "number"
},
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds"
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds"
},
{
"name": "agentMaxLifetimeMinutes",
@@ -22023,7 +22023,7 @@
"kind": "primitive",
"name": "number"
},
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds"
"doc": "Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds"
},
{
"name": "maxAuthFailures",
@@ -22866,6 +22866,15 @@
},
"optional": true,
"doc": "The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups."
},
{
"name": "customerLabel",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Operator-created groups only: the name a managed customer sees for this access instead of the neutral \"Platform support\"; absent when none is set. The group name itself never reaches a customer."
}
]
},
@@ -22997,7 +23006,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "The owned group's name for a self-service delegation; the neutral operator label (\"Platform support\") for every operator-provisioned entry — internal group names never reach a customer."
"doc": "The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral \"Platform support\" (always for direct operator grants) — internal group names never reach a customer."
},
{
"name": "source",
@@ -24307,7 +24316,7 @@
},
"UpdateTenantGroupRequest": {
"kind": "object",
"doc": "Body of PATCH global/tenant-groups/{groupId} — a rename.",
"doc": "Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required.",
"fields": [
{
"name": "name",
@@ -24317,7 +24326,17 @@
"nullable": true
},
"optional": true,
"doc": "New display name."
"doc": "New display name (Global Admin UI only)."
},
{
"name": "customerLabel",
"type": {
"kind": "primitive",
"name": "string",
"nullable": true
},
"optional": true,
"doc": "The name managed customers see for this operator group; an empty string clears it back to \"Platform support\". Rejected on self-service groups."
}
]
},
@@ -1043,7 +1043,7 @@ export interface CollectorConfiguration {
enableAgentSelfMetrics: boolean;
/** Interval in seconds for agent self-metrics snapshots. Default: 60 seconds */
agentSelfMetricsIntervalSeconds: number;
/** Seconds to wait for the Windows Hello wizard after ESP exit. Default: 30 seconds */
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
helloWaitTimeoutSeconds: number;
/** Maximum agent lifetime in minutes. Safety net to prevent zombie agents. 0 = disabled (no lifetime limit). Default: 360 (6 hours). */
agentMaxLifetimeMinutes: number;
@@ -4810,7 +4810,7 @@ export interface TenantConfiguration {
enablePerformanceCollector: boolean;
/** Performance collector interval in seconds Default: 30 seconds */
performanceCollectorIntervalSeconds: number;
/** Seconds to wait for the Windows Hello wizard after ESP exit Default: 30 seconds */
/** Seconds to wait for the Windows Hello wizard after ESP exit (range 30..3600). Values up to 300 keep the built-in 300 s Hello window; larger values extend it. Default: 300 seconds */
helloWaitTimeoutSeconds: number;
/** Maximum consecutive authentication failures (401/403) before the agent shuts down. null = use default (5). 0 = disabled (retry forever). */
maxAuthFailures?: number | null;
@@ -5004,6 +5004,8 @@ export interface TenantGroup {
assignees: TenantGroupAssignment[];
/** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */
ownerTenantId?: string;
/** Operator-created groups only: the name a managed customer sees for this access instead of the neutral "Platform support"; absent when none is set. The group name itself never reaches a customer. */
customerLabel?: string;
}
/** One UPN→group assignment. PK=UPN, RK=groupId in storage. */
@@ -5036,7 +5038,7 @@ export interface TenantManagerItem {
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
ownerTenantId?: string;
ownerDomain?: string;
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
/** The owned group's name for a self-service delegation; for an operator group its customer label, else the neutral "Platform support" (always for direct operator grants) — internal group names never reach a customer. */
name: string;
/** self-service | operator */
source: string;
@@ -5339,10 +5341,12 @@ export interface UpdateTenantAppHomingResponse {
probe: AppHomingProbeWire;
}
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
/** Body of PATCH global/tenant-groups/{groupId} — only the fields to change; at least one is required. */
export interface UpdateTenantGroupRequest {
/** New display name. */
/** New display name (Global Admin UI only). */
name?: string | null;
/** The name managed customers see for this operator group; an empty string clears it back to "Platform support". Rejected on self-service groups. */
customerLabel?: string | null;
}
/** Response of PUT sessions/{sessionId}/annotations/{lane} when both verdict and note were empty and the lane was cleared. */
@@ -0,0 +1,9 @@
# WhiteGloveShellCoreSuccess v1 — Shell-Core 62407 "CommercialOOBE_ESPProgress_WhiteGlove_Success".
# Posted by EspAndHelloTrackerAdapter (fire-once) when the ShellCoreTracker observes the
# technician-flow success page. Payload `accountSetupProgress` (added 2026-09-29) carries the ESP
# registry's AccountSetup progress at that moment ("true" = a subcategory had left notStarted);
# signals from older agents carry no payload.
# Reducer: HandleWhiteGloveShellCoreSuccessV1 → records ShellCoreWhiteGloveSuccessSeen and, when
# the payload parses, AccountSetupProgressAtWhiteGloveSuccess; runs the sealing classifier inline.
# The IME AccountSetup excluder (-40) is lifted only by an explicit "false".
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-09-14T07:25:09.7628703Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-and-hello-tracker-v1","Summary":"WhiteGlove sealing success (coordinator-forwarded)","DerivationInputs":{"subSource":"ShellCoreTracker","accountSetupProgress":"false"}},"Payload":{"accountSetupProgress":"false"}}