Compare commits

...

16 Commits

Author SHA1 Message Date
Oliver Kieselbach 41b33895c5 bump pointer: internal (Mark as Succeeded ends a running agent, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:53:18 +02:00
Oliver Kieselbach 157273726d Mark as Succeeded ends a running agent, and the agent acts on an admin mark once (D-374)
The backend repeats AdminAction on every telemetry response while the session
row carries it. The agent handled each echo anew: a new admin_marked_session
with an immediate upload, whose response carried the next echo. With a running
agent on a Mark-Succeeded session that looped at the device rate limit until
the agent ended; Mark-Failed ended the agent and stayed bounded.

- ServerControlPlane: AdminActionLatch admits the first AdminAction of a
  process; every later one only logs at Debug.
- AdminAction=Succeeded runs the agent's own termination pipeline with outcome
  Succeeded, like Failed: what the portal's confirmation dialog and the docs
  promise, and what the restart path already did.
- HandleAdminActionTests: Succeeded terminates; repeated echoes, a later
  different outcome and concurrent callers are handled once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:51:49 +02:00
Oliver Kieselbach 0d994773d8 bump pointer: internal, .claude/commands (entry channel released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:30:14 +02:00
Oliver Kieselbach 7dd9f81c31 bump pointer: internal, .claude/commands (entry channel, D-373)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:23:50 +02:00
Oliver Kieselbach 7d07cb7805 Marketing clicks and page views on www carry the channel their page load came in through (D-373)
On the public host a telemetry initializer adds the referrer's host and
the utm_source, utm_medium and utm_campaign tags of the entry URL to every
page view and custom event. Both come from the document itself
(document.referrer and the navigation entry), so nothing is stored and no
identifier is added; metrics, dependencies and the portal stay as they are.

The hero and final get-started buttons and the three Pro plan links now
navigate client-side, so landing, plans, get-started and the sign-in click
stay one page load. A guard fails on a plain <a> to /get-started or /plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:23:03 +02:00
Oliver Kieselbach 485a5b4604 bump pointer: internal (TPM-PSS precision released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:08:15 +02:00
Oliver Kieselbach 2109133aee bump pointer: internal, .claude/commands (TPM-PSS TLS probe, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:07 +02:00
Oliver Kieselbach 7ac7a5e8ba TPM-PSS distress carries a TLS probe: protocol, whether the certificate went out, who answered (D-372)
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).

The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.

Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:22:56 +02:00
Oliver Kieselbach 014b3e1e83 bump pointer: .claude/commands (platform-health catalog notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:18:26 +02:00
Oliver Kieselbach b948d32c9a bump pointer: internal, .claude/commands (TPM-PSS precision, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:43 +02:00
Oliver Kieselbach 40d3922fe0 TPM-PSS distress says where the device stood when the failure began: ESP phase, OOBE flag, boot; a later success compares reboot and certificate (D-372)
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.

Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.

Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:41 +02:00
Oliver Kieselbach 37bd4c73f5 chore(deps-dev): bump vite to 8.3.4 in web and MCP
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:21:07 +02:00
dependabot[bot] 9f4c1efe26 chore(deps): bump hono (#190)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [hono](https://github.com/honojs/hono).


Updates `hono` from 4.13.7 to 4.13.13
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.7...v4.13.13)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.13
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 21:11:26 +02:00
Oliver Kieselbach 13a34fb7f1 bump pointer: internal (CI credential isolation released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:44:57 +02:00
Oliver Kieselbach 60a41e9778 CI: signing jobs run in the release environment
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:30:02 +02:00
Oliver Kieselbach 9aa5e46e22 bump pointer: internal (TPM-PSS distress released, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:36:32 +02:00
51 changed files with 1844 additions and 432 deletions
+6 -5
View File
@@ -4,15 +4,16 @@
.DESCRIPTION
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
every own host the workflows, the .github/scripts helpers and the local release build talk to,
plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1
(Workflow gates) fails on any of these hosts spelled out elsewhere under .github/.
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
on any of these hosts spelled out elsewhere under .github/.
The storage URLs derive from the account name, so blob and table can never point at two
different accounts.
A file in the repository rather than a GitHub variable: the local release build reads it too,
and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git.
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
they behave the same in every job and in a local run, and bearer tokens are sent to these
URLs -- where a token goes stays a reviewed line in git.
.PARAMETER ExportToGitHubEnv
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
+2 -2
View File
@@ -9,8 +9,8 @@
live here so they cannot drift between call sites.
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
in CI (azure/login in a job of the `production` or `release` environment), the operator's
own account when a helper runs locally. No secret is read, stored or passed around.
Under GitHub Actions the token is masked in the run log before it is returned.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
+4 -2
View File
@@ -258,12 +258,14 @@ jobs:
# run here -- never a restore or build. The build output arrives as data. The runner
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
#
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
# carries exactly one OIDC subject, so an environment here would break the signing login.
# Runs in the `release` environment: both identities trust its subject, and it admits main
# only. No federated credential trusts the ref, so a job without an environment can neither
# sign nor write storage, whatever its permissions say.
release:
name: Sign and publish
needs: [reserve, build]
runs-on: windows-latest
environment: release
permissions:
contents: write # tag + GitHub Release
id-token: write # code-signing and storage logins, keyless Sigstore signing
+5 -4
View File
@@ -39,11 +39,11 @@ jobs:
# need the embedded loader to carry a signature of its own.
#
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
# here. No `environment:` yet: the code-signing credential is still bound to the ref, and a
# job carries exactly one OIDC subject.
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing login
@@ -198,12 +198,13 @@ jobs:
if-no-files-found: error
retention-days: 1
# Trust zone: signs, verifies and publishes the MSI as data; never builds. No
# `environment:` yet, for the same reason as sign-loader.
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: build
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
+3 -3
View File
@@ -54,6 +54,8 @@ jobs:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
# Both identities trust only the `release` environment, which admits main only.
environment: release
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
# OIDC token it does not need.
permissions:
@@ -74,9 +76,7 @@ jobs:
}
# First, and only to fetch the Key Vault token: azure/login sets the default az
# account and the storage login below has to be the one that stays. See D-223 --
# both federated credentials are bound to the ref, so this job must never carry a
# GitHub `environment:`.
# account and the storage login below has to be the one that stays.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
public string Message { get; set; } = default!;
public int? HttpStatusCode { get; set; }
public string? TpmVersion { get; set; }
public DistressDeviceContext? Context { get; set; }
}
public List<Call> Calls { get; } = new List<Call>();
@@ -33,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
}
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, string? tpmVersion = null)
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, DistressDetail? detail = null)
{
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = tpmVersion });
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = detail?.TpmVersion, Context = detail?.Context });
return Send?.Invoke() ?? Task.CompletedTask;
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The ESP phase a report names, derived from the three ESP registry values. Both value shapes
/// Windows writes are covered: flat status texts (older builds) and nested subcategory objects.
/// </summary>
public sealed class EspPhaseSnapshotTests
{
private const string DoneFlag = "{\"categorySucceeded\":true}";
private const string FailedFlag = "{\"categorySucceeded\":false}";
private const string NestedRunning =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\",\"subcategoryStatusText\":\"Complete\"}," +
"\"AppsSubcategory\":{\"subcategoryState\":\"inProgress\",\"subcategoryStatusText\":\"Installing (1 of 3)\"}}";
private const string NestedAllDoneNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"notRequired\"}}";
private const string NestedFailedNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"failed\"}}";
private const string FlatRunning = "{\"CertificatesSubcategory\":\"Certificates (0 of 1 applied)\",\"AppsSubcategory\":\"Identifying\"}";
private static string Derive(string? preparation, string? deviceSetup, string? accountSetup)
=> EspPhaseSnapshot.Derive(new List<string> { preparation!, deviceSetup!, accountSetup! });
[Fact]
public void No_value_is_none()
{
Assert.Equal("none", Derive(null, null, null));
}
[Fact]
public void Running_category_names_its_phase()
{
Assert.Equal("device_preparation", Derive(NestedRunning, null, null));
Assert.Equal("device_setup", Derive(DoneFlag, NestedRunning, null));
Assert.Equal("account_setup", Derive(DoneFlag, DoneFlag, NestedRunning));
}
[Fact]
public void Flat_status_texts_are_read_too()
{
Assert.Equal("device_setup", Derive(DoneFlag, FlatRunning, null));
}
[Fact]
public void Done_categories_name_the_last_one_reached()
{
Assert.Equal("device_preparation_done", Derive(DoneFlag, null, null));
Assert.Equal("device_setup_done", Derive(DoneFlag, DoneFlag, null));
Assert.Equal("esp_done", Derive(DoneFlag, DoneFlag, DoneFlag));
}
[Fact]
public void All_subcategories_done_counts_as_done_without_the_flag()
{
// Some builds never set categorySucceeded although every subcategory finished.
Assert.Equal("esp_done", Derive(DoneFlag, NestedAllDoneNoFlag, NestedAllDoneNoFlag));
}
[Fact]
public void A_failure_wins_over_everything_after_it()
{
Assert.Equal("device_setup_failed", Derive(DoneFlag, FailedFlag, NestedRunning));
Assert.Equal("account_setup_failed", Derive(DoneFlag, DoneFlag, NestedFailedNoFlag));
}
[Fact]
public void Unparseable_value_counts_as_running()
{
Assert.Equal("device_setup", Derive(DoneFlag, "{not json", null));
}
[Fact]
public void Read_on_this_machine_returns_a_known_token()
{
var phase = EspPhaseSnapshot.Read();
Assert.Matches("^[a-z_]+$", phase);
}
}
}
@@ -164,6 +164,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
var failedBoot = at.AddMinutes(-16);
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("An error occurred while sending the request."),
at, at.AddSeconds(90), true, true, "Succeeded", "fp-a", NewLogger(tmp.Path),
new AutopilotMonitor.Agent.V2.Core.Monitoring.Transport.DistressDeviceContext("completed", "account_setup", failedBoot),
certThumbprint: "6B98620E8BE952FEA7CD9200B899A9A8CCADD1E5");
// The record carries phase and OOBE flag; boot and thumbprint stay beside it.
var summary = persistence.LoadRegistrationFailure()!;
Assert.Equal("account_setup", summary.EspPhase);
Assert.Equal("completed", summary.OobeState);
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => "fp-a", NewLogger(tmp.Path),
currentBootUtc: at.AddHours(1), currentCertThumbprint: "6b98620e8be952fea7cd9200b899a9a8ccadd1e5");
Assert.NotNull(prior);
Assert.Equal("account_setup", prior!.LastEspPhase);
Assert.Equal("completed", prior.LastOobeState);
Assert.True(prior.RebootedSince);
Assert.False(prior.CertChanged);
Assert.False(prior.NetworkChanged);
}
[Fact]
public void Same_boot_and_unknown_sides_are_reported_as_such()
{
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
Assert.False(BackendSessionRegistration.RebootedSince(boot, boot.AddSeconds(30)));
Assert.True(BackendSessionRegistration.RebootedSince(boot, boot.AddMinutes(5)));
Assert.Null(BackendSessionRegistration.RebootedSince(null, boot));
Assert.Null(BackendSessionRegistration.RebootedSince(boot, null));
Assert.True(BackendSessionRegistration.CertificateChanged("AA", "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged(null, "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged("AA", ""));
}
[Fact]
public void Record_from_an_older_agent_leaves_the_new_comparisons_unknown()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
// Written without context, boot or thumbprint — the shape agents up to 2.0.1487 leave.
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("x"), at, at.AddSeconds(5), true, true, null, null, NewLogger(tmp.Path));
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => null, NewLogger(tmp.Path), at.AddHours(1), "AA");
Assert.NotNull(prior);
Assert.Null(prior!.LastEspPhase);
Assert.Null(prior.RebootedSince);
Assert.Null(prior.CertChanged);
}
[Fact]
public void Recording_without_persistence_is_a_no_op()
{
@@ -3,6 +3,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -16,14 +18,16 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Regression gate for the "soft admin-action" semantics change: backend
/// <c>AdminAction=Succeeded</c> is purely informational (timeline entry only, agent keeps
/// running its own path) while <c>AdminAction=Failed</c> drives the agent's own soft-shutdown
/// pipeline via the injected <c>onAdminFailed</c> callback — without forcing self-destruct.
/// Previously both were routed through the full <c>ServerActionDispatcher</c> +
/// <c>terminationHandler.Handle</c> path, which (a) hijacked the agent's own completion flow
/// for Succeeded and (b) crashed with <c>InvalidOperationException: SignalIngress has been
/// stopped</c> when a terminal-drain response arrived after the ingress was torn down.
/// Regression gate for the soft admin-action semantics: backend <c>AdminAction=Succeeded</c>
/// and <c>AdminAction=Failed</c> (portal Mark-Succeeded / Mark-Failed) both drive the agent's
/// own soft-shutdown pipeline via the injected <c>onAdminTerminal</c> callback with that
/// outcome — without forcing self-destruct — and only the first AdminAction of a process is
/// acted on. The backend repeats AdminAction on every telemetry response; acting on each echo
/// re-emitted <c>admin_marked_session</c> with an immediate upload whose response carried the
/// next echo. Earlier, both outcomes were routed through the full
/// <c>ServerActionDispatcher</c> path, which crashed with
/// <c>InvalidOperationException: SignalIngress has been stopped</c> when a terminal-drain
/// response arrived after the ingress was torn down.
/// </summary>
public sealed class HandleAdminActionTests
{
@@ -34,13 +38,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
ApiBaseUrl = "http://localhost",
};
private static global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch NewLatch() =>
new global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch();
private sealed class Rig : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public InformationalEventPost Post { get; }
public List<EnrollmentTerminatedEventArgs> OnAdminFailedCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
public List<EnrollmentTerminatedEventArgs> OnAdminTerminalCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
public Rig()
{
@@ -48,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Post = new InformationalEventPost(Ingress, new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
}
public Action<EnrollmentTerminatedEventArgs> OnAdminFailed => args => OnAdminFailedCalls.Add(args);
public Action<EnrollmentTerminatedEventArgs> OnAdminTerminal => args => OnAdminTerminalCalls.Add(args);
public IEnumerable<FakeSignalIngressSink.PostedSignal> EventsOfType(string eventType) =>
Ingress.Posted.Where(p =>
@@ -61,15 +68,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void Succeeded_emits_admin_marked_session_as_Info_and_does_not_invoke_onAdminFailed()
public void Succeeded_emits_admin_marked_session_as_Info_and_terminates_with_Succeeded()
{
using var rig = new Rig();
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Succeeded",
stageNameAccessor: () => "Completed",
latch: NewLatch(),
stageNameAccessor: () => "EspAccountSetup",
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
@@ -78,24 +86,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Info.ToString(), sev);
// Informational only — agent keeps running its own path.
Assert.Empty(rig.OnAdminFailedCalls);
Assert.Single(rig.OnAdminTerminalCalls);
var args = rig.OnAdminTerminalCalls[0];
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, args.Outcome);
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
Assert.Equal("EspAccountSetup", args.StageName);
Assert.Contains("Admin marked session as Succeeded", args.Details);
// No server_action_* noise — Succeeded isn't a ServerAction.
// No server_action_* noise — an admin mark isn't a ServerAction.
Assert.Empty(rig.EventsOfType("server_action_received"));
Assert.Empty(rig.EventsOfType("server_action_executed"));
}
[Fact]
public void Failed_emits_admin_marked_session_as_Warning_and_invokes_onAdminFailed_once()
public void Failed_emits_admin_marked_session_as_Warning_and_terminates_with_Failed()
{
using var rig = new Rig();
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => "DeviceSetup",
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
@@ -104,8 +117,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Warning.ToString(), sev);
Assert.Single(rig.OnAdminFailedCalls);
var args = rig.OnAdminFailedCalls[0];
Assert.Single(rig.OnAdminTerminalCalls);
var args = rig.OnAdminTerminalCalls[0];
Assert.Equal(EnrollmentTerminationOutcome.Failed, args.Outcome);
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
Assert.Equal("DeviceSetup", args.StageName);
@@ -119,13 +132,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "succeeded",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Empty(rig.OnAdminFailedCalls);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
@@ -137,28 +152,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Quarantined",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Failed_with_null_onAdminFailed_does_not_throw()
public void Null_onAdminTerminal_does_not_throw()
{
// Defensive path: onAdminFailed can be null if the terminate handler wasn't wired yet.
// Defensive path: onAdminTerminal can be null if the terminate handler wasn't wired yet.
using var rig = new Rig();
var ex = Record.Exception(() =>
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: null!,
onAdminTerminal: null!,
agentConfig: Config(),
logger: rig.Logger));
@@ -176,14 +193,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => throw new InvalidOperationException("not started"),
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Null(rig.OnAdminFailedCalls[0].StageName);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Null(rig.OnAdminTerminalCalls[0].StageName);
}
[Fact]
@@ -202,18 +220,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var ex = Record.Exception(() =>
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Succeeded",
latch: NewLatch(),
stageNameAccessor: () => null,
post: stoppedPost,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger));
Assert.Null(ex);
Assert.Empty(rig.OnAdminFailedCalls);
// A lost timeline entry never cancels the admin's decision.
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Failed_emit_race_still_invokes_onAdminFailed()
public void Failed_emit_race_still_terminates()
{
// If the timeline emit fails due to a stopped ingress, the agent's own soft-shutdown
// must still run — the admin's decision to Mark-Failed outranks a lost timeline entry.
@@ -225,14 +247,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => "DeviceSetup",
post: stoppedPost,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
}
[Theory]
[InlineData("Succeeded")]
[InlineData("Failed")]
public void Repeated_echo_is_handled_once(string adminAction)
{
// The backend repeats AdminAction on every telemetry response. Each echo handled anew
// emitted another immediate-upload timeline entry whose response carried the next echo.
using var rig = new Rig();
var latch = NewLatch();
for (var i = 0; i < 3; i++)
{
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: adminAction,
latch: latch,
stageNameAccessor: () => "EspAccountSetup",
post: rig.Post,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
}
Assert.Single(rig.EventsOfType("admin_marked_session"));
Assert.Single(rig.OnAdminTerminalCalls);
}
[Fact]
public void First_admin_action_of_the_process_wins()
{
// The first mark already started the shutdown with its outcome; a later change of
// mind in the portal only updates the session row on the backend.
using var rig = new Rig();
var latch = NewLatch();
foreach (var adminAction in new[] { "Succeeded", "Failed" })
{
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: adminAction,
latch: latch,
stageNameAccessor: () => null,
post: rig.Post,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
}
var emitted = rig.EventsOfType("admin_marked_session").ToList();
Assert.Single(emitted);
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Info.ToString(), sev);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Latch_admits_exactly_one_of_concurrent_callers()
{
// Responses are raised on whichever thread ran the drain, so two can overlap.
var latch = NewLatch();
var admitted = 0;
Parallel.For(0, 64, _ =>
{
if (latch.TryEnter()) Interlocked.Increment(ref admitted);
});
Assert.Equal(1, admitted);
}
private sealed class ThrowingSink : ISignalIngressSink
@@ -5,6 +5,7 @@ using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
@@ -35,8 +36,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
new WebException("Could not create SSL/TLS secure channel.",
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
private static Func<Task<(string TpmVersion, string SchannelEvents)>> Context(string tpm, string schannel) =>
() => Task.FromResult((tpm, schannel));
private static Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> Context(
string tpm, string schannel, string tlsProbe = null!) =>
() => Task.FromResult((tpm, schannel, tlsProbe));
[Fact]
public async Task Broken_tpm_report_completes_only_after_the_send()
@@ -46,14 +48,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
var report = BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205"));
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205", "tls12,-,cc403"));
var call = Assert.Single(reporter.Calls);
Assert.False(report.IsCompleted);
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048",
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205 prov=PCP key=2048",
call.Message);
sendGate.SetResult(true);
@@ -72,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var call = Assert.Single(reporter.Calls);
Assert.Null(call.TpmVersion);
Assert.DoesNotContain("sch=", call.Message);
Assert.Contains("sch=unreadable", call.Message);
}
[Fact]
@@ -115,14 +117,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
await Task.Delay(500);
});
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "tls12,-,cc403"; },
bound,
NewLogger(tmp));
Assert.Equal("IFX 7.63.3353.0", tpm);
Assert.Null(schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, tlsProbe);
}
finally
{
@@ -135,14 +139,62 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
using var tmp = new TempDirectory();
var (tpm, schannel) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => throw new InvalidOperationException("no WMI"),
() => "36887:116",
() => "tls13,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Null(tpm);
Assert.Equal("36887:116", schannel);
Assert.Equal("tls13,-,cc403", tlsProbe);
}
[Fact]
public async Task Schannel_read_that_throws_is_reported_as_unreadable_not_as_empty()
{
using var tmp = new TempDirectory();
var (_, schannel, _) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => throw new UnauthorizedAccessException("System log"),
() => "tls12,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal(SchannelEventReader.Unreadable, schannel);
}
[Fact]
public async Task Tls_probe_that_throws_is_reported_as_error_not_left_out()
{
using var tmp = new TempDirectory();
var (_, _, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => "none",
() => throw new InvalidOperationException("socket"),
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal("error", tlsProbe);
}
[Fact]
public async Task Report_carries_the_context_the_run_started_in()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
var startContext = new DistressDeviceContext("in_progress", "device_setup",
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc));
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "none"), startContext);
var call = Assert.Single(reporter.Calls);
Assert.Same(startContext, call.Context);
Assert.Contains("sch=none", call.Message);
}
}
}
@@ -43,10 +43,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void Query_is_accepted_by_the_event_log_service()
{
// A malformed XPath would only surface as "no events" through ReadSince (it never
// A malformed XPath would only surface as "unreadable" through ReadBetween (it never
// throws), so the query itself is run here.
using (var reader = new EventLogReader(new EventLogQuery(
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1))) { ReverseDirection = true }))
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow)) { ReverseDirection = true }))
{
using (reader.ReadEvent()) { }
}
@@ -55,7 +55,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void No_events_after_now()
{
Assert.Null(SchannelEventReader.ReadSince(DateTime.UtcNow.AddHours(1)));
Assert.Equal(SchannelEventReader.NoEvents,
SchannelEventReader.ReadBetween(DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(2)));
}
[Fact]
public void Window_ends_where_the_diagnostics_begin()
{
var xpath = SchannelEventReader.BuildXPath(
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc),
new DateTime(2026, 10, 21, 6, 55, 30, 250, DateTimeKind.Utc));
Assert.Contains(
"TimeCreated[@SystemTime >= '2026-10-21T06:40:00.000Z' and @SystemTime <= '2026-10-21T06:55:30.250Z']",
xpath);
}
}
}
@@ -0,0 +1,151 @@
using System.Security.Authentication;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TLS probe's request and the reading of its answer: only an echo of the probe's own
/// correlation id counts as the backend, the platform's "Client Certificate Required" page says
/// the certificate did not arrive, and a device value can never split the request.
/// </summary>
public sealed class TlsHandshakeProbeTests
{
private const string SentId = "0123456789abcdef0123456789abcdef";
private static TlsProbeTarget Target(string serial = "SN-1") => new TlsProbeTarget
{
BaseUrl = "https://api.example.test",
TenantId = "00000000-0000-0000-0000-000000000001",
Manufacturer = "Contoso",
Model = "Book 1",
SerialNumber = serial,
AgentVersion = "2.0.1500",
};
private static byte[] Raw(string statusLine, string headers, string body) =>
Encoding.UTF8.GetBytes(statusLine + "\r\n" + headers + "\r\n" + body);
[Fact]
public void Request_asks_for_the_agent_config_with_the_agent_headers()
{
var request = TlsHandshakeProbe.BuildRequest(Target(), "api.example.test", SentId);
Assert.StartsWith(
"GET /api/agent/config?tenantId=00000000-0000-0000-0000-000000000001 HTTP/1.1\r\nHost: api.example.test\r\n",
request);
Assert.Contains("\r\nX-Correlation-ID: " + SentId + "\r\n", request);
Assert.Contains("\r\nX-Device-Manufacturer: Contoso\r\n", request);
Assert.Contains("\r\nX-Device-Model: Book 1\r\n", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN-1\r\n", request);
Assert.Contains("\r\nX-Agent-Version: 2.0.1500\r\n", request);
Assert.EndsWith("\r\nConnection: close\r\n\r\n", request);
}
[Fact]
public void Device_value_with_a_line_break_cannot_add_a_header()
{
var request = TlsHandshakeProbe.BuildRequest(Target("SN\r\nX-Injected: 1"), "api.example.test", SentId);
Assert.DoesNotContain("\r\nX-Injected", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN??X-Injected: 1\r\n", request);
}
[Fact]
public void Echoed_correlation_id_means_the_backend_answered()
{
var raw = Raw("HTTP/1.1 401 Unauthorized", "X-Correlation-ID: " + SentId + "\r\nContent-Type: application/json\r\n", "{}");
Assert.Equal("b401", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Backend_success_is_read_as_backend()
{
var raw = Raw("HTTP/1.1 200 OK", "x-correlation-id: " + SentId + "\r\n", "{\"ok\":true}");
Assert.Equal("b200", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Platform_certificate_required_page_means_the_certificate_did_not_arrive()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("cc403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Certificate_required_page_with_an_echo_is_still_the_backend()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: " + SentId + "\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("b403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Echo_of_another_id_is_someone_else()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: ffffffffffffffffffffffffffffffff\r\n", "<html>Access denied</html>");
Assert.Equal("x403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Stopped_app_page_is_reported_as_stopped()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.StoppedAppPage);
Assert.Equal("st403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("HTTP/1.1 abc\r\n\r\n")]
public void No_parsable_answer_is_reported_as_no_response(string text)
{
Assert.Equal("io(noresponse)", TlsHandshakeProbe.ClassifyAnswer(Encoding.ASCII.GetBytes(text), SentId));
}
[Fact]
public void Microsoft_issued_server_certificate_adds_nothing()
{
using var cert = ServerCertificateObserverTests.SelfSigned(
"CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US");
Assert.Null(TlsHandshakeProbe.ForeignIssuer(cert));
Assert.Null(TlsHandshakeProbe.ForeignIssuer(null!));
}
[Fact]
public void Inspection_issuer_is_named_in_a_compact_token()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Contoso Inspection CA (2026), O=Contoso Ltd");
Assert.Equal("Contoso_Inspection_CA__2026_", TlsHandshakeProbe.ForeignIssuer(cert));
}
[Fact]
public void Long_issuer_is_capped()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=" + new string('A', 80) + ", O=Fabrikam");
Assert.Equal(new string('A', TlsHandshakeProbe.MaxIssuerLength), TlsHandshakeProbe.ForeignIssuer(cert));
}
[Theory]
[InlineData(SslProtocols.Tls12, "tls12")]
[InlineData((SslProtocols)12288, "tls13")]
[InlineData(SslProtocols.Tls11, "tls11")]
[InlineData(SslProtocols.Tls, "tls10")]
[InlineData(SslProtocols.None, "p0")]
public void Protocol_token_names_the_negotiated_version(SslProtocols protocol, string expected)
{
Assert.Equal(expected, TlsHandshakeProbe.ProtocolToken(protocol));
}
}
}
@@ -28,10 +28,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
[Fact]
public void Message_carries_codes_handshake_and_schannel_events()
{
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116");
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116", "tls12,-,cc403");
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 sch=36888:40/1205,36887:116 prov=PCP key=2048",
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205,36887:116 prov=PCP key=2048",
message);
}
@@ -1,5 +1,6 @@
using System;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
@@ -27,28 +28,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
logger: null!);
[Fact]
public void Report_carries_os_build_and_setup_state_at_send_time()
public void Without_a_context_the_report_samples_the_device_when_sent()
{
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, tpmVersion: null);
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, detail: null);
var osBuild = DeviceInfoProvider.GetOsBuild();
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
Assert.Equal(OobeStateReader.Read(), report.OobeState);
Assert.Equal(EspPhaseSnapshot.Read(), report.EspPhase);
Assert.NotNull(report.DeviceBootUtc);
Assert.True(report.DeviceBootUtc <= DateTime.UtcNow);
Assert.Null(report.TpmVersion);
}
}
[Fact]
public void A_given_context_wins_over_the_send_time_sample()
{
// A failed registration samples the device before its first attempt; the report must
// carry that, not where the device stands minutes later.
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail
{
Context = new DistressDeviceContext("in_progress", "device_setup", boot),
});
Assert.Equal("in_progress", report.OobeState);
Assert.Equal("device_setup", report.EspPhase);
Assert.Equal(boot, report.DeviceBootUtc);
}
}
[Fact]
public void Tpm_version_passes_through_capped_at_48()
{
using (var reporter = NewReporter())
{
Assert.Equal("IFX 7.63.3353.0",
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, "IFX 7.63.3353.0").TpmVersion);
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = "IFX 7.63.3353.0" }).TpmVersion);
Assert.Equal(48,
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new string('v', 60)).TpmVersion!.Length);
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = new string('v', 60) }).TpmVersion!.Length);
}
}
@@ -58,7 +81,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
// Every field at the agent's own caps; the agent version as built (not cut to 32).
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403, new string('t', 48));
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403,
new DistressDetail { TpmVersion = new string('t', 48) });
report.CertThumbprint = new string('F', 40);
report.CertSubject = new string('S', 96);
report.CertIssuer = new string('I', 96);
@@ -66,6 +90,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
report.CertNotAfter = DateTime.UtcNow;
report.OsBuild = "26300.97111";
report.OobeState = "unknown_2147483647".Substring(0, 16);
report.EspPhase = "device_preparation_failed";
report.DeviceBootUtc = DateTime.UtcNow;
var json = DistressReporter.SerializeWithinCap(report);
@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// One-shot read of the Autopilot ESP phase from the values <see cref="ProvisioningStatusTracker"/>
/// watches (<c>HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings</c>), for reports taken
/// without the tracker — a failed registration has no session and no tracker yet.
/// <para>
/// <b>Contract:</b> observational only, never decision-engine input. Unlike the tracker it has
/// no settle or fallback timers: a category whose <c>categorySucceeded</c> Windows never sets
/// counts as done once all its subcategories read succeeded or notRequired.
/// </para>
/// </summary>
internal static class EspPhaseSnapshot
{
internal const string None = "none";
internal const string Unavailable = "unavailable";
internal enum CategoryState { Absent, Running, Done, Failed }
// ESP order: device preparation, device setup, account setup.
private static readonly (string ValueName, string Phase)[] Categories =
{
("DevicePreparationCategory.Status", "device_preparation"),
("DeviceSetupCategory.Status", "device_setup"),
("AccountSetupCategory.Status", "account_setup"),
};
/// <summary>
/// <c>device_preparation</c> / <c>device_setup</c> / <c>account_setup</c> while that
/// category runs, <c>&lt;category&gt;_failed</c>, <c>device_preparation_done</c> /
/// <c>device_setup_done</c> when the next category has not appeared (yet, or skipped),
/// <c>esp_done</c>, <c>none</c> without any ESP value, <c>unavailable</c> when the
/// registry cannot be read. Never throws.
/// </summary>
public static string Read()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusTracker.ProvisioningStatusRegistryPath, writable: false))
{
if (key == null) return None;
return Derive(Categories.Select(c => key.GetValue(c.ValueName) as string).ToList());
}
}
catch
{
return Unavailable;
}
}
/// <param name="statusJson">The three category values in ESP order; null where absent.</param>
internal static string Derive(IReadOnlyList<string> statusJson)
{
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Running) return Categories[i].Phase;
var lastDone = states.FindLastIndex(s => s == CategoryState.Done);
if (lastDone < 0) return None;
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
}
/// <summary>
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
/// else that is present, also unparseable JSON), absent (no value).
/// </summary>
internal static CategoryState Classify(string json)
{
if (string.IsNullOrWhiteSpace(json)) return CategoryState.Absent;
try
{
using (var doc = JsonDocument.Parse(json))
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return CategoryState.Running;
var succeeded = ProvisioningStatusTracker.SafeGetBool(root, "categorySucceeded");
if (succeeded == true) return CategoryState.Done;
if (succeeded == false) return CategoryState.Failed;
var subcategories = ProvisioningStatusTracker.ParseSubcategories(root);
if (subcategories.Any(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Failed;
if (subcategories.Count > 0 && subcategories.All(s =>
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Done;
return CategoryState.Running;
}
}
catch (JsonException)
{
return CategoryState.Running;
}
}
}
}
@@ -0,0 +1,50 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Termination;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
/// <summary>
/// Where the device stood when a failure was observed: Windows' OOBE flag, the Autopilot ESP
/// phase and the last boot. Observational only. Sample it when the failing work starts — a
/// failed registration retries for minutes, and the device moves on meanwhile.
/// </summary>
public sealed class DistressDeviceContext
{
public DistressDeviceContext(string oobeState, string espPhase, DateTime? deviceBootUtc)
{
OobeState = oobeState;
EspPhase = espPhase;
DeviceBootUtc = deviceBootUtc;
}
/// <summary><see cref="OobeStateReader"/>: flips to <c>completed</c> at the desktop, not ESP progress.</summary>
public string OobeState { get; }
/// <summary><see cref="EspPhaseSnapshot"/> token.</summary>
public string EspPhase { get; }
/// <summary>Last boot (UTC) from the monotonic uptime counter; null when it could not be read.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Samples all three now. Registry, WinRT and one kernel call; never throws.</summary>
public static DistressDeviceContext Capture()
{
DateTime? boot;
try { boot = ObservationCoverage.DeviceBootUtc(); }
catch { boot = null; }
return new DistressDeviceContext(OobeStateReader.Read(), EspPhaseSnapshot.Read(), boot);
}
}
/// <summary>Optional parts of one distress report.</summary>
public sealed class DistressDetail
{
/// <summary>TPM manufacturer id and firmware (TPM-PSS report only).</summary>
public string TpmVersion { get; set; }
/// <summary>The device context of the failure; null samples it when the report is sent.</summary>
public DistressDeviceContext Context { get; set; }
}
}
@@ -6,7 +6,6 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -127,14 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// is bounded by the 5-second HTTP timeout.
///
/// All anti-flood checks are applied synchronously before the async HTTP call,
/// so repeated calls are cheap when suppressed. Every report carries the OS build and the
/// Windows setup state at send time; <paramref name="tpmVersion"/> only the TPM-PSS one.
/// so repeated calls are cheap when suppressed. Every report carries the OS build and a
/// <see cref="DistressDeviceContext"/> — <paramref name="detail"/>'s, sampled when the
/// failure was observed, or one sampled at send time; the TPM version comes only with the
/// TPM-PSS report.
/// </summary>
public virtual async Task TrySendAsync(
DistressErrorType errorType,
string message,
int? httpStatusCode = null,
string tpmVersion = null)
DistressDetail detail = null)
{
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
@@ -169,7 +170,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = BuildReport(errorType, message, httpStatusCode, tpmVersion);
var report = BuildReport(errorType, message, httpStatusCode, detail);
try
{
@@ -203,9 +204,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
}
/// <summary>The report as sent: construction-time device and cert context plus the send-time OS build and setup state.</summary>
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, string tpmVersion)
/// <summary>
/// The report as sent: construction-time device and cert context, the send-time OS build,
/// and the device context of <paramref name="detail"/> (sampled now when it has none).
/// </summary>
internal DistressReport BuildReport(DistressErrorType errorType, string message, int? httpStatusCode, DistressDetail detail)
{
var context = detail?.Context ?? DistressDeviceContext.Capture();
return new DistressReport
{
TenantId = _tenantId,
@@ -225,8 +230,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
CertNotBefore = _capturedCertNotBefore,
CertNotAfter = _capturedCertNotAfter,
OsBuild = NullIfEmpty(DeviceInfoProvider.GetOsBuild()),
OobeState = OobeStateReader.Read(),
TpmVersion = Truncate(tpmVersion, 48),
OobeState = context.OobeState,
EspPhase = context.EspPhase,
DeviceBootUtc = context.DeviceBootUtc,
TpmVersion = Truncate(detail?.TpmVersion, 48),
};
}
@@ -8,7 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// One-shot read of the Schannel errors the System log recorded since a point in time:
/// One-shot read of the Schannel errors the System log recorded in a time window:
/// 36871 (creating the client credential failed), 36887 (fatal TLS alert received) and
/// 36888 (fatal TLS alert sent). Only the terminal registration-failure path calls it.
/// Another process's TLS failure in the same window shows up too, so the result is a hint.
@@ -17,17 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
internal const int MaxEvents = 2;
/// <summary>The log was read and holds no matching event — a finding, unlike <see cref="Unreadable"/>.</summary>
internal const string NoEvents = "none";
/// <summary>The System log could not be queried; nothing is known about Schannel.</summary>
internal const string Unreadable = "unreadable";
/// <summary>
/// Newest first, at most <see cref="MaxEvents"/> events as <see cref="FormatToken"/> tokens
/// joined by commas (<c>36888:40/1205,36887:116</c>); null when there is none or the log
/// cannot be read. Never throws.
/// joined by commas (<c>36888:40/1205,36887:116</c>); <see cref="NoEvents"/> when there is
/// none, <see cref="Unreadable"/> when the log cannot be read. <paramref name="untilUtc"/>
/// keeps out what the diagnostics after the failure log themselves (the TLS probe's own
/// handshake). Never throws.
/// </summary>
public static string ReadSince(DateTime sinceUtc, AgentLogger logger = null)
public static string ReadBetween(DateTime sinceUtc, DateTime untilUtc, AgentLogger logger = null)
{
var tokens = new List<string>();
try
{
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc)) { ReverseDirection = true };
var query = new EventLogQuery("System", PathType.LogName, BuildXPath(sinceUtc, untilUtc)) { ReverseDirection = true };
using (var reader = new EventLogReader(query))
{
EventRecord record;
@@ -43,18 +51,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
catch (Exception ex)
{
logger?.Debug($"SchannelEventReader: System log not readable ({ex.Message}).");
return null;
return Unreadable;
}
return tokens.Count == 0 ? null : string.Join(",", tokens);
return tokens.Count == 0 ? NoEvents : string.Join(",", tokens);
}
internal static string BuildXPath(DateTime sinceUtc)
internal static string BuildXPath(DateTime sinceUtc, DateTime untilUtc)
{
var since = sinceUtc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
return "*[System[Provider[@Name='Schannel'] and (EventID=36871 or EventID=36887 or EventID=36888)" +
$" and TimeCreated[@SystemTime >= '{since}']]]";
$" and TimeCreated[@SystemTime >= '{XPathTime(sinceUtc)}' and @SystemTime <= '{XPathTime(untilUtc)}']]]";
}
private static string XPathTime(DateTime utc) =>
utc.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
/// <summary>
/// <c>36888:40/1205</c>: the event id and its numeric properties (TLS alert code, Schannel
/// error state). Text properties (target name, certificate subjects) are left out.
@@ -29,8 +29,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private readonly string _registrationFailurePath;
private readonly object _lockObject = new object();
// Agent-local key in registration-failure.json; RegistrationFailureSummary has no such member.
// Agent-local keys in registration-failure.json; RegistrationFailureSummary has no such members.
private const string LocalNetworkFingerprintKey = "NetworkFingerprint";
private const string LocalDeviceBootKey = "DeviceBootUtc";
private const string LocalCertThumbprintKey = "CertThumbprint";
public SessionIdPersistence(string dataDirectory)
{
@@ -180,13 +182,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <see cref="RegistrationFailureSummary.InterceptedRuns"/>), 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. <paramref name="networkFingerprint"/> (the latest run's network)
/// is stored beside the summary, never in it, so it cannot reach the wire. Written through to
/// disk (L.12): the process exits right after and the next boot decides on the record.
/// session never appeared. <paramref name="networkFingerprint"/>, <paramref name="deviceBootUtc"/>
/// and <paramref name="certThumbprint"/> (the latest run's network, boot and client
/// certificate) are stored beside the summary, never in it, so they cannot reach the wire —
/// the next successful start reports only whether they changed. Written through to disk
/// (L.12): the process exits right after and the next boot decides on the record.
/// Returns the merged summary, null when it could not be written. Never throws.
/// </summary>
public RegistrationFailureSummary RecordRegistrationFailure(
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null)
RegistrationFailureSummary latestRun, AgentLogger logger = null, string networkFingerprint = null,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
if (latestRun == null) return null;
lock (_lockObject)
@@ -206,8 +211,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
NetworkLinkUpAtEnd = latestRun.NetworkLinkUpAtEnd,
AttemptWindowSeconds = latestRun.AttemptWindowSeconds,
ConfigFetchOutcome = latestRun.ConfigFetchOutcome,
EspPhase = latestRun.EspPhase,
OobeState = latestRun.OobeState,
};
WriteRegistrationFailure(merged, networkFingerprint);
WriteRegistrationFailure(merged, networkFingerprint, deviceBootUtc, certThumbprint);
logger?.Info($"SessionIdPersistence: recorded failed registration run #{merged.FailedRuns} ({merged.InterceptedRuns} intercepted).");
return merged;
}
@@ -286,7 +293,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
var json = JObject.Parse(File.ReadAllText(_registrationFailurePath));
var summary = json.ToObject<RegistrationFailureSummary>();
return summary != null && summary.FailedRuns > 0
? new RegistrationFailureRecord(summary, (string)json[LocalNetworkFingerprintKey])
? new RegistrationFailureRecord(
summary,
(string)json[LocalNetworkFingerprintKey],
json[LocalDeviceBootKey]?.Type == JTokenType.Date ? json[LocalDeviceBootKey].ToObject<DateTime>().ToUniversalTime() : (DateTime?)null,
(string)json[LocalCertThumbprintKey])
: null;
}
catch
@@ -297,14 +308,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
/// <summary>
/// The summary's own properties plus one agent-local key: the fingerprint sits next to the
/// wire type, never inside it (the summary deserializes without it).
/// The summary's own properties plus the agent-local keys: they sit next to the wire type,
/// never inside it (the summary deserializes without them).
/// </summary>
private void WriteRegistrationFailure(RegistrationFailureSummary summary, string networkFingerprint)
private void WriteRegistrationFailure(
RegistrationFailureSummary summary, string networkFingerprint, DateTime? deviceBootUtc, string certThumbprint)
{
var json = JObject.FromObject(summary);
if (!string.IsNullOrEmpty(networkFingerprint))
json[LocalNetworkFingerprintKey] = networkFingerprint;
if (deviceBootUtc.HasValue)
json[LocalDeviceBootKey] = deviceBootUtc.Value.ToUniversalTime();
if (!string.IsNullOrEmpty(certThumbprint))
json[LocalCertThumbprintKey] = certThumbprint;
var bytes = Encoding.UTF8.GetBytes(json.ToString(Formatting.None));
using (var stream = new FileStream(
@@ -357,20 +373,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// <c>registration-failure.json</c> as the agent keeps it: the <see cref="RegistrationFailureSummary"/>
/// the emergency break may send, plus the network fingerprint of the latest failed run, which
/// stays on the device — only the next successful start's comparison (changed or not) is reported.
/// the emergency break may send, plus the network fingerprint, boot time and client-certificate
/// thumbprint of the latest failed run, which stay on the device — only the next successful
/// start's comparisons (changed or not) are reported.
/// </summary>
public sealed class RegistrationFailureRecord
{
public RegistrationFailureRecord(RegistrationFailureSummary summary, string networkFingerprint)
public RegistrationFailureRecord(
RegistrationFailureSummary summary, string networkFingerprint,
DateTime? deviceBootUtc = null, string certThumbprint = null)
{
Summary = summary ?? throw new ArgumentNullException(nameof(summary));
NetworkFingerprint = networkFingerprint;
DeviceBootUtc = deviceBootUtc;
CertThumbprint = certThumbprint;
}
public RegistrationFailureSummary Summary { get; }
/// <summary>Fingerprint of the latest failed run's network; null when it was unknown.</summary>
public string NetworkFingerprint { get; }
/// <summary>Boot time (UTC) of the latest failed run; null when it was unknown or the record predates it.</summary>
public DateTime? DeviceBootUtc { get; }
/// <summary>Thumbprint of the client certificate the latest failed run used; null when none or unknown.</summary>
public string CertThumbprint { get; }
}
}
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>Who the TLS probe talks to and with which identity headers.</summary>
internal sealed class TlsProbeTarget
{
public string BaseUrl { get; set; }
public string TenantId { get; set; }
public string Manufacturer { get; set; }
public string Model { get; set; }
public string SerialNumber { get; set; }
public string AgentVersion { get; set; }
}
/// <summary>
/// Diagnostic after a terminal <c>SecureChannelFailure</c>: one direct TLS connection to the
/// backend on a raw <see cref="SslStream"/> with the client certificate (system-default
/// protocols, default certificate selection, normal server validation) and one
/// <c>GET /api/agent/config</c> with the agent's own headers — the platform asks for the
/// certificate only once it has seen a path that needs one. The HTTP stack reports only
/// <c>SecureChannelFailure</c>; this says which protocol was negotiated, whether the certificate
/// went out and who answered, or the SSPI code of the failure. Failure path only, never a proxy.
/// <para>
/// Result token: <c>unreachable</c>, <c>hs(srvcert)</c> / <c>hs(0x…)</c> / <c>hs</c> (handshake
/// failed), or <c>&lt;protocol&gt;,&lt;m|-&gt;,&lt;answer&gt;</c> — protocol <c>tls12</c> /
/// <c>tls13</c> / …, <c>m</c> when the session ended mutually authenticated, answer
/// <c>b&lt;status&gt;</c> (the backend answered: the certificate arrived), <c>cc&lt;status&gt;</c>
/// (the platform's "Client Certificate Required" page: it did not), <c>st&lt;status&gt;</c>
/// (stopped app), <c>x&lt;status&gt;</c> (someone else), <c>io(0x…)</c> / <c>io</c> (the
/// exchange failed, e.g. during renegotiation). A server certificate not issued by Microsoft
/// (TLS inspection on the path) appends <c>,i=&lt;issuer CN&gt;</c>.
/// </para>
/// </summary>
internal static class TlsHandshakeProbe
{
internal const string Unreachable = "unreachable";
internal const int MaxResponseBytes = 16 * 1024;
internal const int MaxIssuerLength = 32;
/// <summary>Runs the probe within <paramref name="timeout"/> per network step. Never throws.</summary>
public static string Run(TlsProbeTarget target, X509Certificate2 clientCertificate, TimeSpan timeout, AgentLogger logger = null)
{
Uri baseUri;
try { baseUri = new Uri(target.BaseUrl); }
catch { return Unreachable; }
var host = baseUri.Host;
var port = baseUri.IsDefaultPort ? 443 : baseUri.Port;
var timeoutMs = (int)timeout.TotalMilliseconds;
try
{
using (var tcp = new TcpClient())
{
var connect = tcp.ConnectAsync(host, port);
// A connect that fails after the timeout must not surface as an unobserved
// task exception: the agent writes crash artefacts for those.
connect.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
try
{
if (!connect.Wait(timeout))
return Unreachable;
}
catch
{
return Unreachable;
}
var serverCertificateRejected = false;
string foreignIssuer = null;
using (var ssl = new SslStream(tcp.GetStream(), leaveInnerStreamOpen: false,
userCertificateValidationCallback: (sender, certificate, chain, errors) =>
{
foreignIssuer = ForeignIssuer(certificate);
serverCertificateRejected = errors != SslPolicyErrors.None;
return !serverCertificateRejected;
}))
{
ssl.ReadTimeout = timeoutMs;
ssl.WriteTimeout = timeoutMs;
var certificates = new X509CertificateCollection();
if (clientCertificate != null) certificates.Add(clientCertificate);
try
{
ssl.AuthenticateAsClient(host, certificates, SslProtocols.None, checkCertificateRevocation: false);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: handshake failed: {ex.GetBaseException().Message}");
return (serverCertificateRejected ? "hs(srvcert)" : "hs" + CodeSuffix(ex)) + IssuerSuffix(foreignIssuer);
}
var protocol = ProtocolToken(ssl.SslProtocol);
string answer;
try
{
var correlationId = Guid.NewGuid().ToString("N");
var request = Encoding.ASCII.GetBytes(BuildRequest(target, host, correlationId));
ssl.Write(request, 0, request.Length);
ssl.Flush();
answer = ClassifyAnswer(ReadResponse(ssl), correlationId);
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: exchange failed: {ex.GetBaseException().Message}");
answer = "io" + CodeSuffix(ex);
}
bool mutual;
try { mutual = ssl.IsMutuallyAuthenticated; }
catch { mutual = false; }
return $"{protocol},{(mutual ? "m" : "-")},{answer}{IssuerSuffix(foreignIssuer)}";
}
}
}
catch (Exception ex)
{
logger?.Debug($"TlsHandshakeProbe: failed: {ex.Message}");
return Unreachable;
}
}
internal static string BuildRequest(TlsProbeTarget target, string host, string correlationId)
{
var sb = new StringBuilder();
sb.Append("GET ").Append(Constants.ApiEndpoints.GetAgentConfig)
.Append("?tenantId=").Append(Uri.EscapeDataString(target.TenantId ?? string.Empty)).Append(" HTTP/1.1\r\n");
sb.Append("Host: ").Append(host).Append("\r\n");
sb.Append("User-Agent: AutopilotMonitor.Agent/").Append(HeaderValue(target.AgentVersion)).Append(" tls-probe\r\n");
sb.Append(ResponseOriginClassifier.CorrelationIdHeader).Append(": ").Append(correlationId).Append("\r\n");
AppendHeader(sb, "X-Device-Manufacturer", target.Manufacturer);
AppendHeader(sb, "X-Device-Model", target.Model);
AppendHeader(sb, "X-Device-SerialNumber", target.SerialNumber);
AppendHeader(sb, "X-Agent-Version", target.AgentVersion);
sb.Append("Connection: close\r\n\r\n");
return sb.ToString();
}
/// <summary>
/// Reads until the server closes or <see cref="MaxResponseBytes"/> arrived; a read that
/// times out after the answer began keeps what came (a server that ignores
/// <c>Connection: close</c>).
/// </summary>
private static byte[] ReadResponse(Stream stream)
{
var buffer = new byte[4096];
using (var collected = new MemoryStream())
{
while (collected.Length < MaxResponseBytes)
{
int read;
try
{
read = stream.Read(buffer, 0, buffer.Length);
}
catch (IOException) when (collected.Length > 0)
{
break;
}
if (read <= 0) break;
collected.Write(buffer, 0, read);
}
return collected.ToArray();
}
}
/// <summary>
/// The answer token for a raw HTTP response, through the shared <see cref="ResponseOriginClassifier"/>:
/// an echo of <paramref name="correlationId"/> means the backend wrote it.
/// </summary>
internal static string ClassifyAnswer(byte[] raw, string correlationId)
{
if (!TryParse(raw, out var status, out var headers, out var body))
return "io(noresponse)";
using (var response = new HttpResponseMessage((HttpStatusCode)status))
{
foreach (var header in headers)
response.Headers.TryAddWithoutValidation(header.Key, header.Value);
response.Content = new StringContent(body ?? string.Empty);
var classification = ResponseOriginClassifier.Classify(response, correlationId, body);
switch (classification.Origin)
{
case ResponseOrigin.Backend:
return "b" + status;
case ResponseOrigin.PlatformStopped:
return "st" + status;
default:
return classification.ForeignReason == ResponseClassification.ClientCertificateRequired
? "cc" + status
: "x" + status;
}
}
}
internal static bool TryParse(byte[] raw, out int status, out List<KeyValuePair<string, string>> headers, out string body)
{
status = 0;
headers = new List<KeyValuePair<string, string>>();
body = null;
if (raw == null || raw.Length == 0) return false;
var text = Encoding.UTF8.GetString(raw);
var headEnd = text.IndexOf("\r\n\r\n", StringComparison.Ordinal);
var head = headEnd >= 0 ? text.Substring(0, headEnd) : text;
body = headEnd >= 0 ? text.Substring(headEnd + 4) : string.Empty;
var lines = head.Split(new[] { "\r\n" }, StringSplitOptions.None);
var statusParts = lines[0].Split(' ');
if (statusParts.Length < 2 || !statusParts[0].StartsWith("HTTP/", StringComparison.Ordinal)
|| !int.TryParse(statusParts[1], out status))
return false;
for (var i = 1; i < lines.Length; i++)
{
var colon = lines[i].IndexOf(':');
if (colon > 0)
headers.Add(new KeyValuePair<string, string>(lines[i].Substring(0, colon).Trim(), lines[i].Substring(colon + 1).Trim()));
}
return true;
}
internal static string ProtocolToken(SslProtocols protocol)
{
switch ((int)protocol)
{
case 12288: return "tls13"; // SslProtocols.Tls13
case 3072: return "tls12";
case 768: return "tls11";
case 192: return "tls10";
default: return "p" + ((int)protocol).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
/// <summary>
/// The issuer CN of a server certificate that Microsoft did not issue (the backend's comes
/// from a Microsoft CA), else null: <c>[A-Za-z0-9.-]</c>, others as <c>_</c>, at most
/// <see cref="MaxIssuerLength"/>.
/// </summary>
internal static string ForeignIssuer(X509Certificate certificate)
{
if (certificate == null) return null;
try
{
if (certificate.Issuer.IndexOf("O=Microsoft Corporation", StringComparison.Ordinal) >= 0)
return null;
string cn;
using (var cert2 = new X509Certificate2(certificate))
cn = cert2.GetNameInfo(X509NameType.SimpleName, forIssuer: true);
if (string.IsNullOrEmpty(cn)) return "unknown";
var sb = new StringBuilder(Math.Min(cn.Length, MaxIssuerLength));
foreach (var c in cn)
{
if (sb.Length == MaxIssuerLength) break;
sb.Append((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' ? c : '_');
}
return sb.ToString();
}
catch
{
return "unknown";
}
}
private static string IssuerSuffix(string foreignIssuer) =>
foreignIssuer == null ? string.Empty : ",i=" + foreignIssuer;
private static string CodeSuffix(Exception ex)
{
var code = TransportErrorCodes.InnermostNativeCode(ex);
return code == null ? string.Empty : "(" + code + ")";
}
private static void AppendHeader(StringBuilder sb, string name, string value)
{
if (!string.IsNullOrEmpty(value))
sb.Append(name).Append(": ").Append(HeaderValue(value)).Append("\r\n");
}
/// <summary>ASCII only, no line breaks — a header value must never split the request.</summary>
private static string HeaderValue(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
var sb = new StringBuilder(value.Length);
foreach (var c in value)
sb.Append(c >= 0x20 && c < 0x7F ? c : '?');
return sb.ToString();
}
}
}
@@ -190,19 +190,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// <summary>
/// Language-neutral summary for the distress message, codes instead of the localized
/// error texts: <c>pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029)
/// tls=0x80090326 sch=36888:40/1205 prov=PCP key=2048</c>. <paramref name="tlsCode"/>
/// is the native code of the failed handshake (<see cref="TransportErrorCodes"/>),
/// <paramref name="schannelEvents"/> the System-log tokens (<see cref="SchannelEventReader"/>);
/// both are left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
/// error texts: <c>pkcs1=ok pss256=fail(0x80070423) pss384=fail(0x80090029)
/// tlsp=tls12,-,cc403 sch=none prov=PCP key=2048</c>. <paramref name="tlsCode"/> is the
/// native code of the failed HTTP-stack handshake (<see cref="TransportErrorCodes"/>),
/// <paramref name="tlsProbe"/> the <see cref="TlsHandshakeProbe"/> token,
/// <paramref name="schannelEvents"/> the System-log finding (<see cref="SchannelEventReader"/>);
/// each is left out when null. At most <see cref="MaxDistressMessageLength"/> chars.
/// </summary>
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null)
public string ToDistressMessage(string tlsCode = null, string schannelEvents = null, string tlsProbe = null)
{
var sb = new StringBuilder();
sb.Append("pkcs1=").Append(Pkcs1Sha256Works ? "ok" : "fail");
AppendSignOutcome(sb, " pss256=", PssSha256Works, PssSha256ErrorCode);
AppendSignOutcome(sb, " pss384=", PssSha384Works, PssSha384ErrorCode);
if (!string.IsNullOrEmpty(tlsCode)) sb.Append(" tls=").Append(tlsCode);
if (!string.IsNullOrEmpty(tlsProbe)) sb.Append(" tlsp=").Append(tlsProbe);
if (!string.IsNullOrEmpty(schannelEvents)) sb.Append(" sch=").Append(schannelEvents);
sb.Append(" prov=").Append(ShortProviderName(ProviderName));
if (KeySizeBits > 0) sb.Append(" key=").Append(KeySizeBits);
@@ -149,7 +149,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
distressReporter: distressReporter,
emergencyReporter: emergencyReporter,
authFailureTracker: authFailureTracker,
serverCertificates: serverCertificates);
serverCertificates: serverCertificates,
clientCertificateThumbprint: clientCertificate?.Thumbprint);
}
/// <summary>
@@ -290,6 +291,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// </summary>
public ServerCertificateObserver ServerCertificates { get; }
/// <summary>
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
/// failed run's record so the next successful start can tell whether the certificate changed.
/// </summary>
public string ClientCertificateThumbprint { get; }
public BackendAuthBundle(
BackendApiClient backendApiClient,
NetworkMetrics networkMetrics,
@@ -300,7 +307,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
DistressReporter distressReporter,
EmergencyReporter emergencyReporter,
AuthFailureTracker authFailureTracker,
ServerCertificateObserver serverCertificates)
ServerCertificateObserver serverCertificates,
string clientCertificateThumbprint = null)
{
BackendApiClient = backendApiClient;
NetworkMetrics = networkMetrics;
@@ -312,6 +320,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EmergencyReporter = emergencyReporter;
AuthFailureTracker = authFailureTracker;
ServerCertificates = serverCertificates;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
}
@@ -60,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// the device slept or was suspended while this run tried to register.
var startedUtc = DateTime.UtcNow;
var linkUpAtStart = TryIsNetworkLinkUp();
// Where the device stands before the first attempt: the retries take minutes, and a
// failure is reported (and recorded) with this, not with where it stands at the end.
var startContext = DistressDeviceContext.Capture();
var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient: auth.BackendApiClient,
@@ -68,7 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger: logger,
authFailureTracker: auth.AuthFailureTracker,
emergencyReporter: auth.EmergencyReporter,
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, startedUtc, logger),
onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(
ex, auth, startedUtc, startContext,
new TlsProbeTarget
{
BaseUrl = agentConfig.ApiBaseUrl,
TenantId = agentConfig.TenantId,
Manufacturer = auth.Manufacturer,
Model = auth.Model,
SerialNumber = auth.SerialNumber,
AgentVersion = agentVersion,
},
logger),
// Reuse the single hardened hardware read from the auth bundle so the session row
// matches the security headers the backend validated against (no second WMI query).
deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber),
@@ -78,12 +92,15 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (registrationResult.Outcome == SessionRegistrationOutcome.Succeeded)
{
return SessionRegistrationOutcomeResult.Continue(
registrationResult, ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger));
registrationResult,
ClearAfterSuccess(sessionPersistence, NetworkFingerprint.TryCapture, logger,
startContext.DeviceBootUtc, auth.ClientCertificateThumbprint));
}
var record = RecordFailedRun(
sessionPersistence, registrationResult, startedUtc, DateTime.UtcNow,
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger);
linkUpAtStart, TryIsNetworkLinkUp(), configFetchOutcome, NetworkFingerprint.TryCapture(), logger,
startContext, auth.ClientCertificateThumbprint);
logger.Error(
$"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " +
@@ -106,8 +123,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// it — for a session that never registered, the only account of why) and returns the record
/// including this run; null without persistence or when it could not be written. An
/// intercepted run also counts toward <see cref="RegistrationFailureSummary.InterceptedRuns"/>,
/// which only a successful registration resets. <paramref name="networkFingerprint"/> is this
/// run's network; it is kept beside the record and never leaves the device. Never throws.
/// which only a successful registration resets. <paramref name="startContext"/> supplies the
/// ESP phase and OOBE flag the run started in. <paramref name="networkFingerprint"/>, the
/// boot time and <paramref name="certThumbprint"/> are kept beside the record and never leave
/// the device. Never throws.
/// </summary>
internal static RegistrationFailureSummary RecordFailedRun(
SessionIdPersistence sessionPersistence,
@@ -118,7 +137,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
bool? linkUpAtEnd,
string configFetchOutcome,
string networkFingerprint,
AgentLogger logger)
AgentLogger logger,
DistressDeviceContext startContext = null,
string certThumbprint = null)
{
if (sessionPersistence == null || registrationResult == null) return null;
try
@@ -139,7 +160,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
NetworkLinkUpAtEnd = linkUpAtEnd,
AttemptWindowSeconds = Math.Round(Math.Max(0, (endedUtc - startedUtc).TotalSeconds), 1),
ConfigFetchOutcome = configFetchOutcome,
}, logger, networkFingerprint);
EspPhase = startContext?.EspPhase,
OobeState = startContext?.OobeState,
}, logger, networkFingerprint, startContext?.DeviceBootUtc, certThumbprint);
}
catch (Exception ex)
{
@@ -152,14 +175,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// A successful registration clears <c>registration-failure.json</c>. When earlier starts of
/// this session had failed, the cleared record comes back for this run's <c>agent_started</c>
/// — until now the success erased it silently and late successes were uncountable. The
/// network fingerprints are compared here (<paramref name="captureNetworkFingerprint"/> runs
/// only when there is a record) and never leave the device. Null when there was no record.
/// Never throws.
/// network fingerprints, boot times (<paramref name="currentBootUtc"/>) and client-certificate
/// thumbprints (<paramref name="currentCertThumbprint"/>) are compared here and never leave
/// the device (<paramref name="captureNetworkFingerprint"/> runs only when there is a record).
/// Null when there was no record. Never throws.
/// </summary>
internal static PriorRegistrationFailures ClearAfterSuccess(
SessionIdPersistence sessionPersistence,
Func<string> captureNetworkFingerprint,
AgentLogger logger)
AgentLogger logger,
DateTime? currentBootUtc = null,
string currentCertThumbprint = null)
{
if (sessionPersistence == null) return null;
try
@@ -172,10 +198,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
cleared.Summary.FailedRuns,
cleared.Summary.InterceptedRuns,
cleared.Summary.Outcome,
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()));
NetworkFingerprint.Differs(cleared.NetworkFingerprint, captureNetworkFingerprint?.Invoke()),
cleared.Summary.EspPhase,
cleared.Summary.OobeState,
RebootedSince(cleared.DeviceBootUtc, currentBootUtc),
CertificateChanged(cleared.CertThumbprint, currentCertThumbprint));
logger?.Info(
$"Session registered after {prior.FailedRuns} failed start(s) ({prior.InterceptedRuns} intercepted, " +
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}).");
$"last: {prior.LastOutcome}, network changed: {prior.NetworkChanged?.ToString() ?? "unknown"}, " +
$"last ESP phase: {prior.LastEspPhase ?? "unknown"}, rebooted since: {prior.RebootedSince?.ToString() ?? "unknown"}, " +
$"certificate changed: {prior.CertChanged?.ToString() ?? "unknown"}).");
return prior;
}
catch (Exception ex)
@@ -232,6 +264,27 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
}
/// <summary>
/// Boot times derived from the uptime counter jitter by milliseconds to seconds between two
/// reads of the same boot (NTP corrections shift them further), so only a gap above this
/// counts as a reboot.
/// </summary>
internal static readonly TimeSpan RebootDetectionTolerance = TimeSpan.FromMinutes(2);
/// <summary>True when the two boot times are further apart than <see cref="RebootDetectionTolerance"/>; null when either is unknown.</summary>
internal static bool? RebootedSince(DateTime? failedRunBootUtc, DateTime? currentBootUtc)
{
if (!failedRunBootUtc.HasValue || !currentBootUtc.HasValue) return null;
return (currentBootUtc.Value - failedRunBootUtc.Value).Duration() > RebootDetectionTolerance;
}
/// <summary>True when the thumbprints differ (case-insensitive); null when either is unknown.</summary>
internal static bool? CertificateChanged(string failedRunThumbprint, string currentThumbprint)
{
if (string.IsNullOrEmpty(failedRunThumbprint) || string.IsNullOrEmpty(currentThumbprint)) return null;
return !string.Equals(failedRunThumbprint, currentThumbprint, StringComparison.OrdinalIgnoreCase);
}
private static bool? TryIsNetworkLinkUp()
{
try { return NetworkInterface.GetIsNetworkAvailable(); }
@@ -282,8 +335,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
/// <summary>How long the TPM and System-log reads for the TPM-PSS report may take together.</summary>
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(5);
/// <summary>How long the TPM read, the System-log read and the TLS probe for the TPM-PSS report may take together.</summary>
internal static readonly TimeSpan DiagnosticContextBound = TimeSpan.FromSeconds(10);
/// <summary>Per network step of the TLS probe (connect, handshake, request, response).</summary>
internal static readonly TimeSpan TlsProbeStepTimeout = TimeSpan.FromSeconds(4);
/// <summary>
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
@@ -301,8 +357,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
Exception ex,
BackendAuthBundle auth,
DateTime registrationStartedUtc,
DistressDeviceContext startContext,
TlsProbeTarget probeTarget,
AgentLogger logger)
{
// The Schannel window closes here: the TLS probe below logs its own handshake.
var failureDetectedUtc = DateTime.UtcNow;
try
{
if (!IsSecureChannelFailure(ex))
@@ -324,8 +384,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
probe, ex, auth.DistressReporter, logger,
() => ReadDiagnosticContextAsync(
() => TpmInfoReader.Read()?.Label,
() => SchannelEventReader.ReadSince(registrationStartedUtc, logger),
Task.Delay(DiagnosticContextBound), logger));
() => SchannelEventReader.ReadBetween(registrationStartedUtc, failureDetectedUtc, logger),
() => TlsHandshakeProbe.Run(probeTarget, cert, TlsProbeStepTimeout, logger),
Task.Delay(DiagnosticContextBound), logger),
startContext);
}
catch (Exception probeEx)
{
@@ -337,16 +399,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// Sends <see cref="DistressErrorType.TpmPssUnsupported"/> when the probe shows a TPM-backed
/// key that signs PKCS#1 but not PSS, with the native code of the failed handshake, the
/// Schannel events and the TPM firmware from <paramref name="readContext"/>. The returned
/// task completes only after the send: the run exits right after this callback, and
/// nothing else waits for it. Never throws.
/// Schannel finding, the TLS probe and the TPM firmware from <paramref name="readContext"/>, and the
/// <paramref name="startContext"/> the run began in. The returned task completes only after
/// the send: the run exits right after this callback, and nothing else waits for it. Never throws.
/// </summary>
internal static async Task ReportProbeResultAsync(
TpmPssProbeResult probe,
Exception transportFailure,
DistressReporter reporter,
AgentLogger logger,
Func<Task<(string TpmVersion, string SchannelEvents)>> readContext)
Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> readContext,
DistressDeviceContext startContext = null)
{
try
{
@@ -363,20 +426,21 @@ namespace AutopilotMonitor.Agent.V2.Runtime
"Schannel can withhold the cert from TLS client-auth, blocking mTLS to the backend. " +
"Likely fix: update TPM firmware (common on older TPM 2.0 firmware).");
string tpmVersion = null, schannelEvents = null;
string tpmVersion = null, schannelEvents = SchannelEventReader.Unreadable, tlsProbe = null;
try
{
(tpmVersion, schannelEvents) = await readContext().ConfigureAwait(false);
(tpmVersion, schannelEvents, tlsProbe) = await readContext().ConfigureAwait(false);
}
catch (Exception ex)
{
// The context only enriches the report; it never holds it back.
logger?.Debug($"TPM-PSS report: context read failed ({ex.Message}) — sending without it.");
}
logger?.Info($"TPM-PSS report: tlsProbe={tlsProbe ?? "n/a"} schannel={schannelEvents ?? "n/a"} tpm={tpmVersion ?? "n/a"}");
await reporter.TrySendAsync(
DistressErrorType.TpmPssUnsupported,
probe.ToDistressMessage(tlsCode, schannelEvents),
tpmVersion: tpmVersion).ConfigureAwait(false);
probe.ToDistressMessage(tlsCode, schannelEvents, tlsProbe),
detail: new DistressDetail { TpmVersion = tpmVersion, Context = startContext }).ConfigureAwait(false);
}
else if (probe.IsTpmPssBroken)
{
@@ -392,24 +456,35 @@ namespace AutopilotMonitor.Agent.V2.Runtime
}
}
/// <summary>Schannel finding or TLS-probe token when that read did not finish within the bound.</summary>
internal const string SchannelReadTimeout = "timeout";
/// <summary>
/// Runs both reads in parallel until both finished or <paramref name="bound"/> completed
/// (production: <see cref="DiagnosticContextBound"/>); a read that threw or had not
/// finished by then counts as missing (null). Never throws.
/// Runs the three reads in parallel until all finished or <paramref name="bound"/> completed
/// (production: <see cref="DiagnosticContextBound"/>). A TPM read that threw or had not
/// finished counts as missing (null); the Schannel finding then says
/// <see cref="SchannelEventReader.Unreadable"/> or <see cref="SchannelReadTimeout"/>, so an
/// empty finding is never mistaken for "no events"; the TLS probe says <c>error</c> or
/// <see cref="SchannelReadTimeout"/>. Never throws.
/// </summary>
internal static async Task<(string TpmVersion, string SchannelEvents)> ReadDiagnosticContextAsync(
internal static async Task<(string TpmVersion, string SchannelEvents, string TlsProbe)> ReadDiagnosticContextAsync(
Func<string> readTpmVersion,
Func<string> readSchannelEvents,
Func<string> runTlsProbe,
Task bound,
AgentLogger logger)
{
var tpm = Task.Run(readTpmVersion);
var schannel = Task.Run(readSchannelEvents);
await Task.WhenAny(Task.WhenAll(tpm, schannel), bound).ConfigureAwait(false);
return (ResultOrNull(tpm, "TPM", logger), ResultOrNull(schannel, "Schannel event", logger));
var tlsProbe = Task.Run(runTlsProbe);
await Task.WhenAny(Task.WhenAll(tpm, schannel, tlsProbe), bound).ConfigureAwait(false);
return (
ResultOr(tpm, "TPM", logger, faulted: null, unfinished: null),
ResultOr(schannel, "Schannel event", logger, faulted: SchannelEventReader.Unreadable, unfinished: SchannelReadTimeout),
ResultOr(tlsProbe, "TLS probe", logger, faulted: "error", unfinished: SchannelReadTimeout));
}
private static string ResultOrNull(Task<string> read, string what, AgentLogger logger)
private static string ResultOr(Task<string> read, string what, AgentLogger logger, string faulted, string unfinished)
{
if (read.Status == TaskStatus.RanToCompletion)
return read.Result;
@@ -419,7 +494,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger?.Debug(read.IsFaulted
? $"TPM-PSS report: {what} read failed ({read.Exception?.GetBaseException().Message})."
: $"TPM-PSS report: {what} read still running — sending without it.");
return null;
return read.IsFaulted ? faulted : unfinished;
}
/// <summary>
@@ -477,19 +552,37 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// What the failed starts before a successful registration left in the record it cleared,
/// reported once on that run's <c>agent_started</c> (<c>priorRegistration*</c> keys): whether a
/// further boot rescues intercepted or failed starts becomes countable. Only the fingerprint
/// comparison is carried, never a fingerprint.
/// further boot rescues intercepted or failed starts becomes countable. Only the comparisons of
/// network, boot and client certificate are carried, never a fingerprint, boot time or thumbprint.
/// </summary>
internal sealed class PriorRegistrationFailures
{
public PriorRegistrationFailures(int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged)
public PriorRegistrationFailures(
int failedRuns, int interceptedRuns, string lastOutcome, bool? networkChanged,
string lastEspPhase = null, string lastOobeState = null, bool? rebootedSince = null, bool? certChanged = null)
{
FailedRuns = failedRuns;
InterceptedRuns = interceptedRuns;
LastOutcome = lastOutcome;
NetworkChanged = networkChanged;
LastEspPhase = lastEspPhase;
LastOobeState = lastOobeState;
RebootedSince = rebootedSince;
CertChanged = certChanged;
}
/// <summary>ESP phase the latest failed start began in; null for records that predate it.</summary>
public string LastEspPhase { get; }
/// <summary>Windows' OOBE flag when the latest failed start began; null for records that predate it.</summary>
public string LastOobeState { get; }
/// <summary>The device booted between the latest failed start and this one; null when either boot time is unknown.</summary>
public bool? RebootedSince { get; }
/// <summary>This start uses a different client certificate than the latest failed one; null when either side is unknown.</summary>
public bool? CertChanged { get; }
public int FailedRuns { get; }
public int InterceptedRuns { get; }
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -87,6 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// this makes late starts provable: "completed" on a first_run means OOBE
// was already over when the agent arrived (e.g. IME queue blocked).
{ "oobeStateAtAgentStart", OobeStateReader.Read() },
// The ESP phase from the ESP registry — unlike the OOBE flag, which flips only
// at the desktop, it tells device setup from account setup from done.
{ "espPhaseAtAgentStart", EspPhaseSnapshot.Read() },
};
if (recovered)
@@ -116,6 +120,16 @@ namespace AutopilotMonitor.Agent.V2.Runtime
data["priorRegistrationLastOutcome"] = priorRegistrationFailures.LastOutcome;
if (priorRegistrationFailures.NetworkChanged.HasValue)
data["priorRegistrationNetworkChanged"] = priorRegistrationFailures.NetworkChanged.Value;
// Where the latest failed start began, and what differs now: a reboot in
// between, another client certificate. Comparisons only, no boot time or thumbprint.
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastEspPhase))
data["priorRegistrationLastEspPhase"] = priorRegistrationFailures.LastEspPhase;
if (!string.IsNullOrEmpty(priorRegistrationFailures.LastOobeState))
data["priorRegistrationLastOobeState"] = priorRegistrationFailures.LastOobeState;
if (priorRegistrationFailures.RebootedSince.HasValue)
data["priorRegistrationRebootedSince"] = priorRegistrationFailures.RebootedSince.Value;
if (priorRegistrationFailures.CertChanged.HasValue)
data["priorRegistrationCertChanged"] = priorRegistrationFailures.CertChanged.Value;
}
post.Emit(new EnrollmentEvent
@@ -92,14 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <c>terminate_session</c> <see cref="ServerAction"/> with
/// <c>forceSelfDestruct=true</c> and routes it through the dispatcher, overriding
/// local <c>SelfDestructOnComplete=false</c>.</item>
/// <item><b>AdminAction=Succeeded</b> — informational. Admin clicked Mark-Succeeded in
/// the portal. Emits a single <c>admin_marked_session</c> timeline entry and lets
/// the agent finish its own path.</item>
/// <item><b>AdminAction=Failed</b> — soft admin-driven shutdown. Calls the agent's own
/// <see cref="EnrollmentTerminationHandler.Handle"/> with <c>outcome=Failed</c>,
/// which runs the same analyzers / diagnostics / summary / self-destruct sequence
/// as a locally-detected failure. Self-destruct is governed by local
/// <c>SelfDestructOnComplete</c> — no forced override.</item>
/// <item><b>AdminAction=Succeeded|Failed</b> — soft admin-driven shutdown. Admin clicked
/// Mark-Succeeded / Mark-Failed in the portal. Calls the agent's own
/// <see cref="EnrollmentTerminationHandler.Handle"/> with that outcome, which runs the
/// same analyzers / diagnostics / summary / self-destruct sequence as a locally-detected
/// completion or failure. Self-destruct is governed by local
/// <c>SelfDestructOnComplete</c> — no forced override. The backend repeats AdminAction
/// on every response, so only the first one per process is acted on
/// (<see cref="HandleAdminAction"/>).</item>
/// <item><b>upload.Actions[]</b> — genuinely backend-queued <see cref="ServerAction"/>s
/// (<c>rotate_config</c>, <c>request_diagnostics</c>). Still routed through the
/// dispatcher because these are real actions, not admin-button echoes.</item>
@@ -130,6 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
orchestrator.Transport.SessionGone += (sender, reason) =>
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
var adminActionLatch = new AdminActionLatch();
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
{
if (shutdownComplete.IsSet)
@@ -163,17 +165,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
try { return orchestrator.CurrentState?.Stage.ToString(); }
catch (InvalidOperationException) { return null; }
};
Action<EnrollmentTerminatedEventArgs> onAdminFailed = args =>
Action<EnrollmentTerminatedEventArgs> onAdminTerminal = args =>
{
var handler = terminationHandlerAccessor();
if (handler == null)
{
logger.Warning("AdminAction=Failed received but terminationHandler not constructed yet — ignoring.");
logger.Warning($"AdminAction={args.Outcome} received but terminationHandler not constructed yet — ignoring.");
return;
}
handler.Handle(sender: null, args: args);
};
HandleAdminAction(upload.AdminAction, stageNameAccessor, post, onAdminFailed, agentConfig, logger);
HandleAdminAction(upload.AdminAction, adminActionLatch, stageNameAccessor, post, onAdminTerminal, agentConfig, logger);
}
if (upload.Actions != null && upload.Actions.Count > 0)
@@ -265,16 +267,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
/// <c>server_action_received / _executed</c> noise) and flows straight into the agent's
/// own timeline / termination pipeline.
/// own timeline / termination pipeline — one <c>admin_marked_session</c> entry, then
/// <paramref name="onAdminTerminal"/> with the admin's outcome.
/// <para>
/// The backend repeats AdminAction on every telemetry response while the session row
/// carries it, so <paramref name="latch"/> admits only the first one per process. Acting
/// on every echo looped: each entry is uploaded immediately, and its response carried
/// the next echo.
/// </para>
/// </summary>
internal static void HandleAdminAction(
string adminAction,
AdminActionLatch latch,
Func<string> stageNameAccessor,
InformationalEventPost post,
Action<EnrollmentTerminatedEventArgs> onAdminFailed,
Action<EnrollmentTerminatedEventArgs> onAdminTerminal,
AgentConfiguration agentConfig,
AgentLogger logger)
{
if (!latch.TryEnter())
{
logger.Debug($"Backend repeated AdminAction={adminAction} — already handled in this process, ignoring.");
return;
}
var outcome = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase)
? EnrollmentTerminationOutcome.Succeeded
: EnrollmentTerminationOutcome.Failed;
@@ -311,8 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
logger.Warning($"admin_marked_session emit failed: {ex.Message}");
}
if (outcome != EnrollmentTerminationOutcome.Failed) return;
if (onAdminFailed == null) return;
if (onAdminTerminal == null) return;
string stageName = null;
try { stageName = stageNameAccessor?.Invoke(); }
@@ -320,19 +335,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
try
{
onAdminFailed(new EnrollmentTerminatedEventArgs(
onAdminTerminal(new EnrollmentTerminatedEventArgs(
reason: EnrollmentTerminationReason.DecisionTerminalStage,
outcome: EnrollmentTerminationOutcome.Failed,
outcome: outcome,
stageName: stageName,
terminatedAtUtc: DateTime.UtcNow,
details: "Admin marked session as Failed via portal."));
details: $"Admin marked session as {outcome} via portal."));
}
catch (Exception ex)
{
logger.Error("onAdminFailed (AdminAction=Failed) threw.", ex);
logger.Error($"onAdminTerminal (AdminAction={adminAction}) threw.", ex);
}
}
/// <summary>
/// Admits the first <see cref="HandleAdminAction"/> call of the process. Atomic: responses
/// are raised on whichever thread ran the drain — the drain loop or the terminal drain —
/// so two can arrive at once.
/// </summary>
internal sealed class AdminActionLatch
{
private int _entered;
public bool TryEnter() => Interlocked.Exchange(ref _entered, 1) == 0;
}
/// <summary>
/// Maps a <c>terminate_session</c> ServerAction's <c>adminOutcome</c> param onto an
/// <see cref="EnrollmentTerminationOutcome"/>. Portal Mark-Succeeded was previously
@@ -558,6 +558,35 @@ public class DistressValidationTests
input, ReportDistressFunction.MaxOobeStateLength, ReportDistressFunction.OobeStatePattern));
}
[Theory]
[InlineData("device_setup", "device_setup")]
[InlineData("account_setup_failed", "account_setup_failed")]
[InlineData("esp_done", "esp_done")]
[InlineData("device_preparation_failed", "device_preparation_failed")]
[InlineData("Device_Setup", null)]
[InlineData("device-setup", null)]
[InlineData("device_setup2", null)]
[InlineData("an_esp_phase_token_far_beyond_the_cap", null)]
[InlineData(null, null)]
public void SanitizeToken_EspPhase(string? input, string? expected)
{
Assert.Equal(expected, ReportDistressFunction.SanitizeToken(
input, ReportDistressFunction.MaxEspPhaseLength, ReportDistressFunction.EspPhasePattern));
}
[Fact]
public void SanitizeDeviceBoot_KeepsBootsWithinThirtyDaysBeforeTheReport()
{
var report = FixedUtcNow;
Assert.Equal(report.AddMinutes(-20), ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(-20), report));
Assert.Equal(report.AddDays(-30), ReportDistressFunction.SanitizeDeviceBoot(report.AddDays(-30), report));
Assert.Equal(report.AddMinutes(4), ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(4), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(report.AddDays(-31), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(report.AddMinutes(6), report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(DateTime.MinValue, report));
Assert.Null(ReportDistressFunction.SanitizeDeviceBoot(null, report));
}
[Fact]
public void SanitizeToken_OsBuildExactlyAtCap_Kept()
{
@@ -175,6 +175,7 @@ public class GetTpmPssUnsupportedFunctionTests
older.TpmVersion = "IFX 7.63.3353.0";
older.OsBuild = "26100.1000";
older.OobeState = "in_progress";
older.EspPhase = "device_setup";
var newer = MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN1", T0.AddHours(1));
newer.OsBuild = "26200.2000";
newer.OobeState = "completed";
@@ -187,6 +188,7 @@ public class GetTpmPssUnsupportedFunctionTests
Assert.Equal("IFX 7.63.3353.0", item.TpmVersion);
Assert.Equal("26200.2000", item.OsBuild);
Assert.Equal("completed", item.OobeState);
Assert.Equal("device_setup", item.EspPhase);
}
[Fact]
@@ -199,6 +201,7 @@ public class GetTpmPssUnsupportedFunctionTests
Assert.Null(item.TpmVersion);
Assert.Null(item.OsBuild);
Assert.Null(item.OobeState);
Assert.Null(item.EspPhase);
}
// =========================================================================
@@ -34,6 +34,8 @@ public class TableDistressReportRepositoryMappingTests
OsBuild = "26200.6899",
OobeState = "in_progress",
TpmVersion = "IFX 7.63.3353.0",
EspPhase = "account_setup",
DeviceBootUtc = T0.AddMinutes(-20),
};
[Fact]
@@ -58,6 +58,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["OsBuild"] = Truncate(entry.OsBuild, 32),
["OobeState"] = Truncate(entry.OobeState, 16),
["TpmVersion"] = Truncate(entry.TpmVersion, 48),
["EspPhase"] = Truncate(entry.EspPhase, 32),
["DeviceBootUtc"] = entry.DeviceBootUtc,
};
}
@@ -142,6 +144,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
OsBuild = entity.GetString("OsBuild"),
OobeState = entity.GetString("OobeState"),
TpmVersion = entity.GetString("TpmVersion"),
EspPhase = entity.GetString("EspPhase"),
DeviceBootUtc = entity.GetDateTimeOffset("DeviceBootUtc")?.UtcDateTime,
};
}
@@ -55,6 +55,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
internal const int MaxOsBuildLength = 32;
internal const int MaxOobeStateLength = 16;
internal const int MaxTpmVersionLength = 48;
internal const int MaxEspPhaseLength = 32;
internal static readonly TimeSpan MaxDeviceBootAge = TimeSpan.FromDays(30);
internal static readonly TimeSpan MaxTimestampAge = TimeSpan.FromHours(24);
internal static readonly TimeSpan MaxTimestampFuture = TimeSpan.FromMinutes(5);
@@ -81,6 +83,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
@"^[a-z0-9_]+\z",
RegexOptions.Compiled);
// EspPhaseSnapshot tokens: device_setup, account_setup_failed, esp_done, none, ...
internal static readonly Regex EspPhasePattern = new Regex(
@"^[a-z_]+\z",
RegexOptions.Compiled);
public ReportDistressFunction(
ILogger<ReportDistressFunction> logger,
TenantConfigurationService tenantConfigService,
@@ -208,6 +215,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
var osBuild = SanitizeToken(report.OsBuild, MaxOsBuildLength, OsBuildPattern);
var oobeState = SanitizeToken(report.OobeState, MaxOobeStateLength, OobeStatePattern);
var tpmVersion = Sanitize(report.TpmVersion, MaxTpmVersionLength);
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
// Persist to Table Storage
var entry = new DistressReportEntry
@@ -233,6 +242,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
OsBuild = osBuild,
OobeState = oobeState,
TpmVersion = tpmVersion,
EspPhase = espPhase,
DeviceBootUtc = deviceBootUtc,
};
await _repository.SaveDistressReportAsync(tenantId, entry);
@@ -327,9 +338,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} oobe={OobeState} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", oobeState ?? "n/a", tpmVersion ?? "n/a",
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
// Custom event for KQL queries:
@@ -356,6 +368,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["OsBuild"] = osBuild ?? string.Empty,
["OobeState"] = oobeState ?? string.Empty,
["TpmVersion"] = tpmVersion ?? string.Empty,
["EspPhase"] = espPhase ?? string.Empty,
["DeviceBootUtc"] = deviceBootUtc?.ToString("O") ?? string.Empty,
});
return req.CreateResponse(HttpStatusCode.OK);
@@ -398,6 +412,18 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return pattern.IsMatch(trimmed) ? trimmed : null;
}
/// <summary>
/// The device boot time as UTC when it lies within <see cref="MaxDeviceBootAge"/> before and
/// <see cref="MaxTimestampFuture"/> after the report time, otherwise null.
/// </summary>
internal static DateTime? SanitizeDeviceBoot(DateTime? value, DateTime reportTimestamp)
{
if (!value.HasValue) return null;
var boot = value.Value.Kind == DateTimeKind.Utc ? value.Value : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
var age = reportTimestamp - boot;
return age >= -MaxTimestampFuture && age <= MaxDeviceBootAge ? boot : null;
}
/// <summary>The tenant bell for the first TPM-PSS report of a device.</summary>
internal static string BuildTpmPssBellMessage(string serialNumber, string? manufacturer, string? model)
{
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
Success = true,
Aggregated = aggregated,
TotalRawReports = totalRawReports,
DataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot sign with RSA-PSS, and their agent could not connect when it sent the report; connectedAt marks a device that uploaded with its certificate afterwards. Serial number, manufacturer, model, OS build, setup state and TPM version are self-reported by devices."
DataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot sign with RSA-PSS, and their agent could not connect when it sent the report; connectedAt marks a device that uploaded with its certificate afterwards. Serial number, manufacturer, model, OS build, ESP phase, Windows OOBE flag and TPM version are self-reported by devices."
});
}
catch (Exception ex)
@@ -129,6 +129,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
TpmVersion = newestFirst.Select(r => r.TpmVersion).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
OsBuild = newestFirst.Select(r => r.OsBuild).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
OobeState = newestFirst.Select(r => r.OobeState).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
EspPhase = newestFirst.Select(r => r.EspPhase).FirstOrDefault(v => !string.IsNullOrEmpty(v)),
ConnectedAt = connectedAt,
};
})
+86 -110
View File
@@ -1510,9 +1510,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1530,9 +1527,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1544,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1570,9 +1561,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1590,9 +1578,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1610,9 +1595,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1630,9 +1612,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1650,9 +1629,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1731,13 +1707,13 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
@@ -2080,9 +2056,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
"cpu": [
"arm"
],
@@ -2097,9 +2073,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
"cpu": [
"arm64"
],
@@ -2114,9 +2090,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
"cpu": [
"arm64"
],
@@ -2131,9 +2107,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
"cpu": [
"x64"
],
@@ -2148,9 +2124,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
"cpu": [
"x64"
],
@@ -2165,9 +2141,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
"cpu": [
"arm"
],
@@ -2182,9 +2158,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
"cpu": [
"arm64"
],
@@ -2199,9 +2175,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
"cpu": [
"arm64"
],
@@ -2216,9 +2192,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
"cpu": [
"ppc64"
],
@@ -2233,9 +2209,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
"cpu": [
"s390x"
],
@@ -2250,9 +2226,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
"cpu": [
"x64"
],
@@ -2267,9 +2243,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
"cpu": [
"x64"
],
@@ -2284,9 +2260,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
"cpu": [
"arm64"
],
@@ -2301,9 +2277,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
"cpu": [
"arm64"
],
@@ -2318,9 +2294,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
"cpu": [
"x64"
],
@@ -4146,9 +4122,9 @@
}
},
"node_modules/hono": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"version": "4.13.13",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz",
"integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -4826,9 +4802,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
@@ -5196,9 +5172,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
@@ -5216,9 +5192,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -5354,13 +5330,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.146.0",
"@oxc-project/types": "=0.154.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -5370,21 +5346,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
"@rolldown/binding-android-arm-eabi": "1.2.14",
"@rolldown/binding-android-arm64": "1.2.14",
"@rolldown/binding-darwin-arm64": "1.2.14",
"@rolldown/binding-darwin-x64": "1.2.14",
"@rolldown/binding-freebsd-x64": "1.2.14",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
"@rolldown/binding-linux-arm64-musl": "1.2.14",
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
"@rolldown/binding-linux-x64-gnu": "1.2.14",
"@rolldown/binding-linux-x64-musl": "1.2.14",
"@rolldown/binding-openharmony-arm64": "1.2.14",
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/router": {
@@ -5973,16 +5949,16 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -5999,7 +5975,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -1825,6 +1825,8 @@ export interface DistressReportEntry {
osBuild?: string;
oobeState?: string;
tpmVersion?: string;
espPhase?: string;
deviceBootUtc?: string;
}
/** Success body of GET global/distress-reports: all pre-auth distress reports (Global Admin). */
@@ -5729,8 +5731,10 @@ export interface TpmPssUnsupportedItem {
tpmVersion?: string;
/** OS build of the most recent report; null from agents that predate it. */
osBuild?: string;
/** Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it. */
/** Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it. */
oobeState?: string;
/** Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it. */
espPhase?: string;
/** Last upload of the newest certificate-authenticated session of this serial when that upload came after LastSeen: the device connected after its last report. Null while it has not, and when the lookup failed. */
connectedAt?: string;
}
@@ -50,5 +50,7 @@ namespace AutopilotMonitor.Shared.DataAccess
public string? OsBuild { get; set; }
public string? OobeState { get; set; }
public string? TpmVersion { get; set; }
public string? EspPhase { get; set; }
public DateTime? DeviceBootUtc { get; set; }
}
}
@@ -190,12 +190,29 @@ namespace AutopilotMonitor.Shared.Models
public string? OsBuild { get; set; }
/// <summary>
/// Windows setup (OOBE) state when the report was sent: <c>not_started</c>,
/// <c>in_progress</c>, <c>completed</c>, <c>unknown_&lt;n&gt;</c> or <c>unavailable</c>.
/// Kept server-side only as <c>[a-z0-9_]</c>, at most 16 chars. Null from older agents.
/// Windows' own OOBE flag (<c>SystemSetupInfo.OutOfBoxExperienceState</c>: <c>not_started</c>,
/// <c>in_progress</c>, <c>completed</c>, <c>unknown_&lt;n&gt;</c>, <c>unavailable</c>) — it
/// flips to <c>completed</c> at the desktop and says nothing about ESP progress (see
/// <see cref="EspPhase"/>). Sampled with <see cref="EspPhase"/>. Kept server-side only as
/// <c>[a-z0-9_]</c>, at most 16 chars. Null from older agents.
/// </summary>
public string? OobeState { get; set; }
/// <summary>
/// Autopilot ESP phase from the ESP registry: <c>device_preparation</c>,
/// <c>device_setup</c>, <c>account_setup</c> (running), <c>&lt;phase&gt;_failed</c>,
/// <c>device_setup_done</c>, <c>esp_done</c>, <c>none</c> or <c>unavailable</c>. A failed
/// registration samples it before its first attempt, other reports when sent. Kept
/// server-side only as <c>[a-z_]</c>, at most 32 chars. Null from older agents.
/// </summary>
public string? EspPhase { get; set; }
/// <summary>
/// When the device last booted (UTC), sampled with <see cref="EspPhase"/>. Kept server-side
/// only within 30 days before and 5 minutes after <see cref="Timestamp"/>. Null from older agents.
/// </summary>
public DateTime? DeviceBootUtc { get; set; }
/// <summary>
/// TPM manufacturer id and firmware version (e.g. <c>IFX 7.63.3353.0</c>), sent with
/// <see cref="DistressErrorType.TpmPssUnsupported"/> only. Sanitized, at most 48 chars.
@@ -47,5 +47,9 @@ namespace AutopilotMonitor.Shared.Models
/// <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; }
/// <summary>Autopilot ESP phase when the latest failed run started registering (see <c>DistressReport.EspPhase</c>).</summary>
public string? EspPhase { get; set; }
/// <summary>Windows' OOBE flag when the latest failed run started registering (see <c>DistressReport.OobeState</c>).</summary>
public string? OobeState { get; set; }
}
}
@@ -226,9 +226,12 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>OS build of the most recent report; null from agents that predate it.</summary>
public string? OsBuild { get; set; }
/// <summary>Windows setup state of the most recent report (<c>in_progress</c>, <c>completed</c>, …); null from agents that predate it.</summary>
/// <summary>Windows' OOBE flag of the most recent report (<c>in_progress</c>, <c>completed</c>, …; not ESP progress); null from agents that predate it.</summary>
public string? OobeState { get; set; }
/// <summary>Autopilot ESP phase of the most recent report that carries one (<c>device_setup</c>, <c>account_setup</c>, <c>esp_done</c>, …); null from agents that predate it.</summary>
public string? EspPhase { get; set; }
/// <summary>
/// Last upload of the newest certificate-authenticated session of this serial when that
/// upload came after <see cref="LastSeen"/>: the device connected after its last report.
@@ -346,20 +346,35 @@ export function DistressReportsSection({
<dd className="text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.agentVersion}</dd>
</div>
)}
{(selectedReport.osBuild || selectedReport.oobeState || selectedReport.tpmVersion) && (
<div className="grid grid-cols-3 gap-3">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">OS Build</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.osBuild ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Setup State</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.oobeState ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">TPM</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tpmVersion ?? "-"}</dd>
{(selectedReport.osBuild || selectedReport.oobeState || selectedReport.tpmVersion || selectedReport.espPhase) && (
<div>
<div className="grid grid-cols-3 gap-3">
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">OS Build</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.osBuild ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">TPM</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.tpmVersion ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Device Boot</dt>
<dd className="text-gray-900 dark:text-gray-100 mt-0.5 text-xs">
{selectedReport.deviceBootUtc ? new Date(selectedReport.deviceBootUtc).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">ESP Phase</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.espPhase ?? "-"}</dd>
</div>
<div>
<dt className="font-medium text-gray-500 dark:text-gray-400">Windows OOBE Flag</dt>
<dd className="font-mono text-gray-900 dark:text-gray-100 mt-0.5">{selectedReport.oobeState ?? "-"}</dd>
</div>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
ESP phase, OOBE flag and boot are taken when the failure began (a failed registration: before its first attempt). The OOBE flag turns &quot;completed&quot; at the desktop; it does not track ESP progress.
</p>
</div>
)}
{selectedReport.isCloudPc && (
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LandingNavbar } from "../../components/landing/LandingNavbar";
import { SignupConsentCta } from "../../components/landing/SignupConsentCta";
import { SiteFooter } from "../../components/SiteFooter";
@@ -102,13 +103,14 @@ export default function GetStartedPage() {
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
When you need more later: the{" "}
<a
href="/plans/"
<Link
href="/plans"
prefetch={false}
data-track="get_started_pro_plan"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
Pro plan
</a>{" "}
</Link>{" "}
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
same service, nothing to migrate.
</p>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -73,6 +73,18 @@ describe("marketing page tracking", () => {
expect(html).toMatch(/<section\b[^>]*\sid="push"[^>]*\sdata-track-section="push"/);
});
// A plain <a> loads a new document: the click's entry channel (lib/entryAttribution.ts) would
// turn into the own host. The static markup cannot tell a <Link> from an <a>, so the source is read.
it("links to get-started and plans are client-side Links, so the page load and its entry channel carry over", () => {
const root = join(__dirname, "..", "..");
const plain = ["components", "app"].flatMap(dir =>
readdirSync(join(root, dir), { encoding: "utf-8", recursive: true })
.filter(file => file.endsWith(".tsx") && !file.includes("__tests__"))
.filter(file => /<a\b[^>]*\shref="\/(?:get-started|plans)\b/.test(readFileSync(join(root, dir, file), "utf-8")))
);
expect(plain).toEqual([]);
});
it("the AI page carries every question text in its static HTML", () => {
const html = render(AiPage);
const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/'/g, "&#x27;");
@@ -20,9 +20,9 @@ export function ClosingCta() {
Free, open source, deployed in minutes.
<br />
{"Need SLAs, support, or MSP delegation? "}
<a href="/plans/" data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
<Link href="/plans" prefetch={false} data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
There&apos;s a Pro plan
</a>
</Link>
.
</p>
</div>
@@ -1,3 +1,5 @@
import Link from "next/link";
export function FinalCta() {
return (
<section data-track-section="final_cta" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
@@ -6,24 +8,26 @@ export function FinalCta() {
Your next enrollment doesn&apos;t have to be a black box.
</h2>
<div className="shrink-0 lg:text-right">
<a
href="/get-started/"
<Link
href="/get-started"
prefetch={false}
data-track="final_get_started"
className="inline-block px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
>
Start monitoring now
</a>
</Link>
<p className="mt-3 text-sm text-[var(--lp-ink-faint)] lg:text-right">
Free, open source, deployed in minutes.
<span className="block mt-0.5">
Need SLAs, support, or MSP delegation?{" "}
<a
href="/plans/"
<Link
href="/plans"
prefetch={false}
data-track="final_pro_plan"
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
>
There&apos;s a Pro plan
</a>
</Link>
.
</span>
</p>
@@ -1,3 +1,4 @@
import Link from "next/link";
import { HeroShots } from "./HeroShots";
import { DOCS_URL } from "@/lib/config";
@@ -50,13 +51,14 @@ export function Hero() {
</p>
<div className="shrink-0">
<div className="flex items-center gap-3">
<a
href="/get-started/"
<Link
href="/get-started"
prefetch={false}
data-track="hero_get_started"
className="px-6 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
>
Get started
</a>
</Link>
<a
href={DOCS_URL}
data-track="hero_docs"
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { entryAttribution, stampEntryAttribution } from "../entryAttribution";
const PAGE = "https://www.example.test/";
describe("entry referrer", () => {
it.each([
["no referrer", "", "direct"],
["a search engine origin", "https://search.example.test/", "search.example.test"],
["a full URL, cut to its host", "https://blog.example.test/2026/10/post?ref=feed&id=42", "blog.example.test"],
["an Android app referrer", "android-app://com.example.reader/", "com.example.reader"],
["upper case", "https://Social.EXAMPLE.test/", "social.example.test"],
["a port", "http://intranet.example.test:8080/wiki/page", "intranet.example.test:8080"],
["an earlier page load on the own host", "https://www.example.test/plans/", "www.example.test"],
["no host", "about:blank", "unknown"],
["garbage", "not a url", "unknown"],
])("%s", (_name, referrer, expected) => {
expect(entryAttribution(referrer, PAGE).entryReferrer).toBe(expected);
});
it("caps a long host", () => {
const host = `${"a".repeat(120)}.example.test`;
expect(entryAttribution(`https://${host}/`, PAGE).entryReferrer).toBe(host.slice(0, 100));
});
});
describe("entry UTM tags", () => {
it("reads source, medium and campaign of the document URL", () => {
const entry = entryAttribution("", `${PAGE}?utm_source=social-net&utm_medium=social&utm_campaign=launch_2026.10`);
expect(entry).toEqual({
entryReferrer: "direct",
entryUtmSource: "social-net",
entryUtmMedium: "social",
entryUtmCampaign: "launch_2026.10",
});
});
it("leaves a missing tag out", () => {
expect(entryAttribution("", `${PAGE}get-started/?utm_source=newsletter`)).toEqual({
entryReferrer: "direct",
entryUtmSource: "newsletter",
});
expect(entryAttribution("", PAGE)).toEqual({ entryReferrer: "direct" });
});
it("never reads utm_content, utm_term or other parameters", () => {
const entry = entryAttribution("", `${PAGE}?utm_content=recipient-123&utm_term=abc&ref=x&utm_source=event`);
expect(entry).toEqual({ entryReferrer: "direct", entryUtmSource: "event" });
});
it("lower-cases and trims a tag", () => {
expect(entryAttribution("", `${PAGE}?utm_source=%20Social-Net%20`).entryUtmSource).toBe("social-net");
});
it.each([
["spaces inside", "summit+2026"],
["an address", "someone%40example.test"],
["an empty value", ""],
["a leading separator", "-launch"],
["too long", "a".repeat(65)],
])("reports a tag with %s as other", (_name, value) => {
expect(entryAttribution("", `${PAGE}?utm_campaign=${value}`).entryUtmCampaign).toBe("other");
});
it("keeps the referrer when the document URL cannot be parsed", () => {
expect(entryAttribution("https://search.example.test/", "not a url")).toEqual({ entryReferrer: "search.example.test" });
});
});
describe("stampEntryAttribution", () => {
const entry = { entryReferrer: "search.example.test", entryUtmSource: "newsletter" };
it.each([["PageviewData"], ["EventData"]])("stamps a %s item and keeps its data", (baseType) => {
const envelope = { baseType, data: { tenantId: "t", theme: "dark" } as Record<string, unknown> };
expect(stampEntryAttribution(envelope, entry)).toBe(true);
expect(envelope.data).toEqual({ tenantId: "t", theme: "dark", ...entry });
});
it("creates the data bag when the item has none", () => {
const envelope: { baseType: string; data?: Record<string, unknown> } = { baseType: "EventData" };
stampEntryAttribution(envelope, entry);
expect(envelope.data).toEqual(entry);
});
it.each([["MetricData"], ["RemoteDependencyData"], ["PageviewPerformanceData"], ["ExceptionData"], [undefined]])(
"leaves a %s item alone",
(baseType) => {
const envelope = { baseType, data: { theme: "dark" } as Record<string, unknown> };
expect(stampEntryAttribution(envelope, entry)).toBe(false);
expect(envelope.data).toEqual({ theme: "dark" });
}
);
});
@@ -6,6 +6,8 @@ import {
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
import { API_BASE_URL, PORTAL_URL, SITE_URL } from "@/lib/config";
import type { TelemetryContext } from "./appInsights";
import { readEntryAttribution, stampEntryAttribution } from "./entryAttribution";
import { isOnPublicHost } from "./hostRouting";
import { normalizeRoute, toWebVitalTelemetry } from "./webVitals";
import { buildOwnOriginNonApiPatterns, shouldDropDependency, stripPageViewFragments } from "./webTelemetryExclusions";
@@ -78,6 +80,13 @@ export function createAppInsights(connectionString: string, context: TelemetryCo
appInsights.addTelemetryInitializer((envelope) => {
stripPageViewFragments(envelope);
});
// Public host: page views and custom events carry the channel this page load came in through.
const entry = isOnPublicHost() ? readEntryAttribution() : null;
if (entry) {
appInsights.addTelemetryInitializer((envelope) => {
stampEntryAttribution(envelope, entry);
});
}
appInsights.loadAppInsights();
// Returning false drops the item (D-277); undefined keeps it.
@@ -0,0 +1,73 @@
/**
* The channel a page load on the public host came in through: the referrer's host and the UTM
* tags of the URL the document was loaded with. Both stay the same for the whole page load —
* a client-side navigation changes neither `document.referrer` nor the navigation entry — so
* nothing is stored. Stamped on page views and custom events, counted per channel, never per
* person (D-304, D-305).
*/
export type EntryAttribution = Readonly<Record<string, string>>;
/** Only these three tags: `utm_content` and `utm_term` are where mail tools put recipient ids. */
const UTM_PROPERTIES = [
["utm_source", "entryUtmSource"],
["utm_medium", "entryUtmMedium"],
["utm_campaign", "entryUtmCampaign"],
] as const;
const UTM_VALUE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const MAX_REFERRER_HOST = 100;
/** `baseType` of the SDK items that carry the entry: page views and custom events. */
const STAMPED_BASE_TYPES: readonly string[] = ["PageviewData", "EventData"];
/** The referrer's host only, never its path or query; `direct` without a referrer. */
function referrerHost(referrer: string): string {
if (!referrer) return "direct";
try {
const host = new URL(referrer).host.toLowerCase();
return host ? host.slice(0, MAX_REFERRER_HOST) : "unknown";
} catch {
return "unknown";
}
}
export function entryAttribution(referrer: string, documentUrl: string): EntryAttribution {
const entry: Record<string, string> = { entryReferrer: referrerHost(referrer) };
let params: URLSearchParams;
try {
params = new URL(documentUrl).searchParams;
} catch {
return entry;
}
for (const [param, property] of UTM_PROPERTIES) {
const value = params.get(param);
if (value === null) continue;
const normalized = value.trim().toLowerCase();
entry[property] = UTM_VALUE.test(normalized) ? normalized : "other";
}
return entry;
}
/** This page load's entry, read in the browser. */
export function readEntryAttribution(): EntryAttribution {
const navigation = performance.getEntriesByType("navigation")[0];
return entryAttribution(document.referrer, navigation?.name || window.location.href);
}
/** The fields of an ITelemetryItem the stamp reads and writes. */
interface EnvelopeLike {
baseType?: string;
data?: Record<string, unknown>;
}
/**
* For `addTelemetryInitializer`: adds the entry to a page view or custom event, in place. Other
* item kinds are left alone. Returns true when it stamped, for the tests — the initializer must
* not pass it on, a returned false drops the item.
*/
export function stampEntryAttribution(envelope: EnvelopeLike, entry: EntryAttribution): boolean {
if (!envelope.baseType || !STAMPED_BASE_TYPES.includes(envelope.baseType)) return false;
envelope.data = Object.assign(envelope.data ?? {}, entry);
return true;
}
@@ -9035,6 +9035,22 @@
"name": "string"
},
"optional": true
},
{
"name": "espPhase",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "deviceBootUtc",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
}
]
},
@@ -26231,7 +26247,16 @@
"name": "string"
},
"optional": true,
"doc": "Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it."
"doc": "Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it."
},
{
"name": "espPhase",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it."
},
{
"name": "connectedAt",
@@ -1825,6 +1825,8 @@ export interface DistressReportEntry {
osBuild?: string;
oobeState?: string;
tpmVersion?: string;
espPhase?: string;
deviceBootUtc?: string;
}
/** Success body of GET global/distress-reports: all pre-auth distress reports (Global Admin). */
@@ -5729,8 +5731,10 @@ export interface TpmPssUnsupportedItem {
tpmVersion?: string;
/** OS build of the most recent report; null from agents that predate it. */
osBuild?: string;
/** Windows setup state of the most recent report (in_progress, completed, …); null from agents that predate it. */
/** Windows' OOBE flag of the most recent report (in_progress, completed, …; not ESP progress); null from agents that predate it. */
oobeState?: string;
/** Autopilot ESP phase of the most recent report that carries one (device_setup, account_setup, esp_done, …); null from agents that predate it. */
espPhase?: string;
/** Last upload of the newest certificate-authenticated session of this serial when that upload came after LastSeen: the device connected after its last report. Null while it has not, and when the lookup failed. */
connectedAt?: string;
}
+78 -114
View File
@@ -1428,9 +1428,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1447,9 +1444,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1466,9 +1460,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1485,9 +1476,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1696,9 +1684,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1716,9 +1701,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1736,9 +1718,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1756,9 +1735,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1776,9 +1752,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1796,9 +1769,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1816,9 +1786,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1836,9 +1803,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1917,13 +1881,13 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
@@ -2249,9 +2213,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz",
"integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
"cpu": [
"arm"
],
@@ -2266,9 +2230,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz",
"integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
"cpu": [
"arm64"
],
@@ -2283,9 +2247,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz",
"integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
"cpu": [
"arm64"
],
@@ -2300,9 +2264,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz",
"integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
"cpu": [
"x64"
],
@@ -2317,9 +2281,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz",
"integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
"cpu": [
"x64"
],
@@ -2334,9 +2298,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz",
"integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
"cpu": [
"arm"
],
@@ -2351,9 +2315,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz",
"integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
"cpu": [
"arm64"
],
@@ -2368,9 +2332,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz",
"integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
"cpu": [
"arm64"
],
@@ -2385,9 +2349,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz",
"integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
"cpu": [
"ppc64"
],
@@ -2402,9 +2366,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz",
"integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
"cpu": [
"s390x"
],
@@ -2419,9 +2383,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz",
"integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
"cpu": [
"x64"
],
@@ -2436,9 +2400,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz",
"integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
"cpu": [
"x64"
],
@@ -2453,9 +2417,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz",
"integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
"cpu": [
"arm64"
],
@@ -2470,9 +2434,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz",
"integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
"cpu": [
"arm64"
],
@@ -2487,9 +2451,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz",
"integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
"cpu": [
"x64"
],
@@ -8148,13 +8112,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.146.0",
"@oxc-project/types": "=0.154.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -8164,21 +8128,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.5",
"@rolldown/binding-android-arm64": "1.2.5",
"@rolldown/binding-darwin-arm64": "1.2.5",
"@rolldown/binding-darwin-x64": "1.2.5",
"@rolldown/binding-freebsd-x64": "1.2.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.5",
"@rolldown/binding-linux-arm64-gnu": "1.2.5",
"@rolldown/binding-linux-arm64-musl": "1.2.5",
"@rolldown/binding-linux-ppc64-gnu": "1.2.5",
"@rolldown/binding-linux-s390x-gnu": "1.2.5",
"@rolldown/binding-linux-x64-gnu": "1.2.5",
"@rolldown/binding-linux-x64-musl": "1.2.5",
"@rolldown/binding-openharmony-arm64": "1.2.5",
"@rolldown/binding-win32-arm64-msvc": "1.2.5",
"@rolldown/binding-win32-x64-msvc": "1.2.5"
"@rolldown/binding-android-arm-eabi": "1.2.14",
"@rolldown/binding-android-arm64": "1.2.14",
"@rolldown/binding-darwin-arm64": "1.2.14",
"@rolldown/binding-darwin-x64": "1.2.14",
"@rolldown/binding-freebsd-x64": "1.2.14",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
"@rolldown/binding-linux-arm64-musl": "1.2.14",
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
"@rolldown/binding-linux-x64-gnu": "1.2.14",
"@rolldown/binding-linux-x64-musl": "1.2.14",
"@rolldown/binding-openharmony-arm64": "1.2.14",
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
"node_modules/run-parallel": {
@@ -9393,16 +9357,16 @@
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -9419,7 +9383,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -9471,9 +9435,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {