diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs
new file mode 100644
index 00000000..05775bb1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs
@@ -0,0 +1,100 @@
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Runtime;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Tests for : Phase 3 (auth-clients bundle) and
+ /// Phase 5 (telemetry-clients result) extracts from Program.RunAgent.
+ ///
+ public sealed class BackendClientFactoryTests
+ {
+ private static AgentLogger NewLogger(string path)
+ => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
+
+ private static AgentConfiguration MinimalBootstrapTokenConfig()
+ => new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example.invalid",
+ TenantId = "00000000-0000-0000-0000-000000000123",
+ SessionId = "11111111-1111-1111-1111-111111111111",
+ BootstrapToken = "test-token",
+ UseBootstrapTokenAuth = true,
+ UseClientCertAuth = false,
+ MaxAuthFailures = 3,
+ AuthFailureTimeoutMinutes = 30,
+ };
+
+ // ============================================================ BuildAuthClients
+
+ [Fact]
+ public void BuildAuthClients_returns_bundle_with_all_clients_populated()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var config = MinimalBootstrapTokenConfig();
+
+ var bundle = BackendClientFactory.BuildAuthClients(config, agentVersion: "1.2.3.4", logger: logger);
+
+ Assert.NotNull(bundle);
+ Assert.NotNull(bundle.BackendApiClient);
+ Assert.NotNull(bundle.DistressReporter);
+ Assert.NotNull(bundle.EmergencyReporter);
+ Assert.NotNull(bundle.AuthFailureTracker);
+ // HardwareInfo.GetHardwareInfo always returns string fields (possibly empty on
+ // non-WMI environments) — the bundle must surface whatever it produced.
+ Assert.NotNull(bundle.Manufacturer);
+ Assert.NotNull(bundle.Model);
+ Assert.NotNull(bundle.SerialNumber);
+ }
+
+ [Fact]
+ public void BuildAuthClients_does_not_attempt_cert_lookup_when_bootstrap_auth_used()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var config = MinimalBootstrapTokenConfig();
+
+ var bundle = BackendClientFactory.BuildAuthClients(config, agentVersion: "1.2.3.4", logger: logger);
+
+ // With UseClientCertAuth=false, BackendApiClient skips the cert-store search
+ // entirely → ClientCertificate is null and no AuthCertificateMissing distress
+ // is dispatched. Asserts the bundle is consistent with that path.
+ Assert.Null(bundle.BackendApiClient.ClientCertificate);
+ }
+
+ // =========================================================== TelemetryClientResult
+
+ [Fact]
+ public void TelemetryClientResult_Exit_carries_exit_code_and_no_clients()
+ {
+ var result = TelemetryClientResult.Exit(4);
+
+ Assert.True(result.ShouldExit);
+ Assert.Equal(4, result.ExitCode);
+ Assert.Null(result.MtlsHttpClient);
+ Assert.Null(result.Uploader);
+ }
+
+ [Fact]
+ public void TelemetryClientResult_Continue_carries_clients_and_zero_exit_code()
+ {
+ using var tmp = new TempDirectory();
+ using var fakeHttpClient = new System.Net.Http.HttpClient();
+
+ // BackendTelemetryUploader is hard to construct purely (needs auth tracker etc.) —
+ // pass null, the Continue() factory does not validate. The Result-type contract
+ // is what we exercise here.
+ var result = TelemetryClientResult.Continue(fakeHttpClient, uploader: null);
+
+ Assert.False(result.ShouldExit);
+ Assert.Equal(0, result.ExitCode);
+ Assert.Same(fakeHttpClient, result.MtlsHttpClient);
+ Assert.Null(result.Uploader);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 3ca6a560..0d27eb8d 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -197,53 +197,13 @@ namespace AutopilotMonitor.Agent.V2
var isWhiteGloveResume = bootstrap.IsWhiteGloveResume;
var cleanupServiceFactory = bootstrap.CleanupServiceFactory;
- var backendApiClient = new BackendApiClient(
- baseUrl: agentConfig.ApiBaseUrl,
- configuration: agentConfig,
- logger: logger,
- agentVersion: GetAgentVersion());
-
- // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so
- // Config-fetch failures (auth vs network) flow to the correct channel. Also fires
- // an initial AuthCertificateMissing distress when the MDM cert was expected but
- // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend
- // via the cert-less distress channel).
- var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger);
- var distressReporter = new DistressReporter(
- baseUrl: agentConfig.ApiBaseUrl,
- tenantId: agentConfig.TenantId,
- manufacturer: hardwareForReporters.Manufacturer,
- model: hardwareForReporters.Model,
- serialNumber: hardwareForReporters.SerialNumber,
- agentVersion: GetAgentVersion(),
- logger: logger);
-
- var emergencyReporter = new EmergencyReporter(
- apiClient: backendApiClient,
- sessionId: agentConfig.SessionId,
- tenantId: agentConfig.TenantId,
- agentVersion: GetAgentVersion(),
- logger: logger);
-
- if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null)
- {
- _ = distressReporter.TrySendAsync(
- DistressErrorType.AuthCertificateMissing,
- "MDM certificate not found in LocalMachine or CurrentUser store");
- }
-
- // Central observer for consecutive 401/403 responses. Initialised with the CLI/bootstrap
- // defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge
- // so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the
- // shutdown signal is available, to trigger a clean agent exit when the limit is hit.
- // V1 parity — the distress reporter is plumbed in at construction so the tracker is
- // the single dispatch point for auth-failure distress (first failure only).
- var authFailureTracker = new AuthFailureTracker(
- maxFailures: agentConfig.MaxAuthFailures,
- timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes,
- clock: SystemClock.Instance,
- logger: logger,
- distressReporter: distressReporter);
+ // Phase 3 (BackendApiClient + reporters + auth-failure tracker) is encapsulated
+ // in BackendClientFactory; see Runtime/BackendClientFactory.cs.
+ var auth = Runtime.BackendClientFactory.BuildAuthClients(agentConfig, GetAgentVersion(), logger);
+ var backendApiClient = auth.BackendApiClient;
+ var distressReporter = auth.DistressReporter;
+ var emergencyReporter = auth.EmergencyReporter;
+ var authFailureTracker = auth.AuthFailureTracker;
var remoteConfigService = new RemoteConfigService(
backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker);
@@ -315,45 +275,16 @@ namespace AutopilotMonitor.Agent.V2
logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}");
}
- HttpClient mtlsHttpClient;
- BackendTelemetryUploader uploader;
- try
+ // Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in
+ // BackendClientFactory. Construction failures map to V1-parity exit codes
+ // (4 = mTLS, 5 = uploader). See Runtime/BackendClientFactory.cs.
+ var telemetry = Runtime.BackendClientFactory.BuildTelemetryClients(agentConfig, auth, GetAgentVersion(), logger);
+ if (telemetry.ShouldExit)
{
- // Share BackendApiClient.NetworkMetrics so the mTLS pipeline records every
- // outbound request — most importantly BackendTelemetryUploader's POST
- // /api/agent/telemetry, which dominates per-session traffic. Without this the
- // agent_metrics_snapshot net_total_requests undercounts ~25-30x (only the
- // legacy BackendApiClient calls would show up).
- mtlsHttpClient = MtlsHttpClientFactory.Create(
- resolver: new DefaultCertificateResolver(),
- logger: logger,
- metrics: backendApiClient.NetworkMetrics);
- }
- catch (InvalidOperationException ex)
- {
- logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex);
- return 4;
- }
-
- try
- {
- uploader = new BackendTelemetryUploader(
- httpClient: mtlsHttpClient,
- baseUrl: agentConfig.ApiBaseUrl,
- tenantId: agentConfig.TenantId,
- manufacturer: hardwareForReporters.Manufacturer,
- model: hardwareForReporters.Model,
- serialNumber: hardwareForReporters.SerialNumber,
- bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
- agentVersion: GetAgentVersion(),
- authFailureTracker: authFailureTracker,
- logger: logger); // Plan §5 Fix 5 — upload-cadence logging
- }
- catch (Exception ex)
- {
- logger.Error("BackendTelemetryUploader construction failed.", ex);
- return 5;
+ return telemetry.ExitCode;
}
+ var mtlsHttpClient = telemetry.MtlsHttpClient;
+ var uploader = telemetry.Uploader;
// V1 parity (MonitoringService.Start, MonitoringService.RegisterSessionAsync) —
// POST /api/agent/register-session with 5-retry exponential backoff (2s/4s/8s/16s)
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs
new file mode 100644
index 00000000..2bd3e500
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs
@@ -0,0 +1,220 @@
+using System;
+using System.Net.Http;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Runtime
+{
+ ///
+ /// Phase 3 + Phase 5 of 's RunAgent: backend HTTP-clients
+ /// (BackendApiClient + reporters + auth-failure tracker) and the mTLS-backed telemetry
+ /// uploader. Phase 4 (RemoteConfig fetch + merge + binary-integrity check) lives in
+ /// RunAgent between the two factory calls and consumes Phase 3's clients to
+ /// produce the merged .
+ ///
+ internal static class BackendClientFactory
+ {
+ ///
+ /// Phase 3 — construct the backend HTTP clients, the distress / emergency reporters
+ /// and the auth-failure tracker. Fires a one-shot AuthCertificateMissing
+ /// distress when client-cert auth is configured but no cert was found in either
+ /// LocalMachine or CurrentUser store (Legacy parity — pre-MDM-enrollment dead-end
+ /// surface). Tracker ceilings reflect the CLI/bootstrap defaults; tenant-policy
+ /// overrides are applied later via
+ /// once RemoteConfigMerger has run.
+ ///
+ public static BackendAuthBundle BuildAuthClients(
+ AgentConfiguration agentConfig,
+ string agentVersion,
+ AgentLogger logger)
+ {
+ if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
+ if (logger == null) throw new ArgumentNullException(nameof(logger));
+
+ var backendApiClient = new BackendApiClient(
+ baseUrl: agentConfig.ApiBaseUrl,
+ configuration: agentConfig,
+ logger: logger,
+ agentVersion: agentVersion);
+
+ // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so
+ // Config-fetch failures (auth vs network) flow to the correct channel. Also fires
+ // an initial AuthCertificateMissing distress when the MDM cert was expected but
+ // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend
+ // via the cert-less distress channel).
+ var hardware = HardwareInfo.GetHardwareInfo(logger);
+ var distressReporter = new DistressReporter(
+ baseUrl: agentConfig.ApiBaseUrl,
+ tenantId: agentConfig.TenantId,
+ manufacturer: hardware.Manufacturer,
+ model: hardware.Model,
+ serialNumber: hardware.SerialNumber,
+ agentVersion: agentVersion,
+ logger: logger);
+
+ var emergencyReporter = new EmergencyReporter(
+ apiClient: backendApiClient,
+ sessionId: agentConfig.SessionId,
+ tenantId: agentConfig.TenantId,
+ agentVersion: agentVersion,
+ logger: logger);
+
+ if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null)
+ {
+ _ = distressReporter.TrySendAsync(
+ DistressErrorType.AuthCertificateMissing,
+ "MDM certificate not found in LocalMachine or CurrentUser store");
+ }
+
+ // Central observer for consecutive 401/403 responses. Initialised with the CLI/bootstrap
+ // defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge
+ // so tenant-policy overrides take effect. ThresholdExceeded is wired in RunAgent, once
+ // the shutdown signal is available, to trigger a clean agent exit when the limit is hit.
+ // V1 parity — the distress reporter is plumbed in at construction so the tracker is
+ // the single dispatch point for auth-failure distress (first failure only).
+ var authFailureTracker = new AuthFailureTracker(
+ maxFailures: agentConfig.MaxAuthFailures,
+ timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes,
+ clock: SystemClock.Instance,
+ logger: logger,
+ distressReporter: distressReporter);
+
+ return new BackendAuthBundle(
+ backendApiClient: backendApiClient,
+ manufacturer: hardware.Manufacturer,
+ model: hardware.Model,
+ serialNumber: hardware.SerialNumber,
+ distressReporter: distressReporter,
+ emergencyReporter: emergencyReporter,
+ authFailureTracker: authFailureTracker);
+ }
+
+ ///
+ /// Phase 5 — construct the mTLS-backed and the
+ /// . The uploader's
+ /// instance is shared with the legacy 's pipeline so
+ /// the agent_metrics_snapshot net_total_requests reflects every outbound HTTP call,
+ /// including the dominant /api/agent/telemetry POST (Plan §5 Fix 5 — without
+ /// this the counter undercounts ~25-30x).
+ ///
+ public static TelemetryClientResult BuildTelemetryClients(
+ AgentConfiguration agentConfig,
+ BackendAuthBundle auth,
+ string agentVersion,
+ AgentLogger logger)
+ {
+ if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
+ if (auth == null) throw new ArgumentNullException(nameof(auth));
+ if (logger == null) throw new ArgumentNullException(nameof(logger));
+
+ HttpClient mtlsHttpClient;
+ try
+ {
+ // Share BackendApiClient.NetworkMetrics so the mTLS pipeline records every
+ // outbound request — most importantly BackendTelemetryUploader's POST
+ // /api/agent/telemetry, which dominates per-session traffic. Without this the
+ // agent_metrics_snapshot net_total_requests undercounts ~25-30x (only the
+ // legacy BackendApiClient calls would show up).
+ mtlsHttpClient = MtlsHttpClientFactory.Create(
+ resolver: new DefaultCertificateResolver(),
+ logger: logger,
+ metrics: auth.BackendApiClient.NetworkMetrics);
+ }
+ catch (InvalidOperationException ex)
+ {
+ logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex);
+ return TelemetryClientResult.Exit(4);
+ }
+
+ BackendTelemetryUploader uploader;
+ try
+ {
+ uploader = new BackendTelemetryUploader(
+ httpClient: mtlsHttpClient,
+ baseUrl: agentConfig.ApiBaseUrl,
+ tenantId: agentConfig.TenantId,
+ manufacturer: auth.Manufacturer,
+ model: auth.Model,
+ serialNumber: auth.SerialNumber,
+ bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
+ agentVersion: agentVersion,
+ authFailureTracker: auth.AuthFailureTracker,
+ logger: logger); // Plan §5 Fix 5 — upload-cadence logging
+ }
+ catch (Exception ex)
+ {
+ logger.Error("BackendTelemetryUploader construction failed.", ex);
+ return TelemetryClientResult.Exit(5);
+ }
+
+ return TelemetryClientResult.Continue(mtlsHttpClient, uploader);
+ }
+ }
+
+ ///
+ /// Phase 3 outcome — the four backend-facing clients and the cached hardware identity
+ /// they were built with. RunAgent retains this bundle through the rest of startup so
+ /// the lifecycle / termination wiring can read each client without re-construction.
+ ///
+ internal sealed class BackendAuthBundle
+ {
+ public BackendApiClient BackendApiClient { get; }
+ public string Manufacturer { get; }
+ public string Model { get; }
+ public string SerialNumber { get; }
+ public DistressReporter DistressReporter { get; }
+ public EmergencyReporter EmergencyReporter { get; }
+ public AuthFailureTracker AuthFailureTracker { get; }
+
+ public BackendAuthBundle(
+ BackendApiClient backendApiClient,
+ string manufacturer,
+ string model,
+ string serialNumber,
+ DistressReporter distressReporter,
+ EmergencyReporter emergencyReporter,
+ AuthFailureTracker authFailureTracker)
+ {
+ BackendApiClient = backendApiClient;
+ Manufacturer = manufacturer;
+ Model = model;
+ SerialNumber = serialNumber;
+ DistressReporter = distressReporter;
+ EmergencyReporter = emergencyReporter;
+ AuthFailureTracker = authFailureTracker;
+ }
+ }
+
+ ///
+ /// Phase 5 outcome — either an early exit (V1 parity: 4 = mTLS construction failure,
+ /// 5 = uploader construction failure) or a Continue payload carrying the live mTLS
+ /// and .
+ ///
+ internal sealed class TelemetryClientResult
+ {
+ public bool ShouldExit { get; }
+ public int ExitCode { get; }
+ public HttpClient MtlsHttpClient { get; }
+ public BackendTelemetryUploader Uploader { get; }
+
+ private TelemetryClientResult(bool shouldExit, int exitCode, HttpClient mtls, BackendTelemetryUploader uploader)
+ {
+ ShouldExit = shouldExit;
+ ExitCode = exitCode;
+ MtlsHttpClient = mtls;
+ Uploader = uploader;
+ }
+
+ public static TelemetryClientResult Exit(int code)
+ => new TelemetryClientResult(true, code, null, null);
+
+ public static TelemetryClientResult Continue(HttpClient mtls, BackendTelemetryUploader uploader)
+ => new TelemetryClientResult(false, 0, mtls, uploader);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 526204c8..5b0cffbc 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-367
+370
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index bacbcf47..8c8b8bca 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1177
+1179