diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AgentRuntimeConfigTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AgentRuntimeConfigTests.cs
new file mode 100644
index 00000000..f34fa186
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AgentRuntimeConfigTests.cs
@@ -0,0 +1,90 @@
+using System;
+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 AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Tests for : Phase 4 extract from Program.RunAgent.
+ /// The Resolve path itself depends on live HTTP / WMI / cert-store side effects that
+ /// are exercised by the existing scenario suite; here we cover the contract surface
+ /// (RuntimeConfigBundle property mapping + argument validation).
+ ///
+ public sealed class AgentRuntimeConfigTests
+ {
+ private static AgentLogger NewLogger(string path)
+ => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
+
+ private static AgentConfiguration MinimalConfig()
+ => 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,
+ };
+
+ [Fact]
+ public void RuntimeConfigBundle_ctor_preserves_all_fields()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var auth = BackendClientFactory.BuildAuthClients(MinimalConfig(), agentVersion: "1.0", logger: logger);
+
+ // Build the supporting state without going through Resolve (which would fetch
+ // from a real backend). We exercise the bundle-shape contract here.
+ var rcs = new AutopilotMonitor.Agent.V2.Core.Configuration.RemoteConfigService(
+ auth.BackendApiClient, "tenant-x", logger,
+ auth.EmergencyReporter, auth.DistressReporter, auth.AuthFailureTracker);
+ var rc = new AgentConfigResponse();
+ var merge = new RemoteConfigMergeResult();
+
+ var bundle = new RuntimeConfigBundle(rcs, rc, merge);
+
+ Assert.Same(rcs, bundle.RemoteConfigService);
+ Assert.Same(rc, bundle.RemoteConfig);
+ Assert.Same(merge, bundle.MergeResult);
+ }
+
+ [Fact]
+ public void Resolve_throws_on_null_agent_config()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var auth = BackendClientFactory.BuildAuthClients(MinimalConfig(), agentVersion: "1.0", logger: logger);
+
+ Assert.Throws(
+ () => AgentRuntimeConfig.Resolve(null, auth, "1.0", consoleMode: false, logger: logger));
+ }
+
+ [Fact]
+ public void Resolve_throws_on_null_auth_bundle()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+
+ Assert.Throws(
+ () => AgentRuntimeConfig.Resolve(MinimalConfig(), auth: null, agentVersion: "1.0", consoleMode: false, logger: logger));
+ }
+
+ [Fact]
+ public void Resolve_throws_on_null_logger()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp.Path);
+ var auth = BackendClientFactory.BuildAuthClients(MinimalConfig(), agentVersion: "1.0", logger: logger);
+
+ Assert.Throws(
+ () => AgentRuntimeConfig.Resolve(MinimalConfig(), auth, "1.0", consoleMode: false, logger: null));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 0d27eb8d..c66565e8 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -205,75 +205,13 @@ namespace AutopilotMonitor.Agent.V2
var emergencyReporter = auth.EmergencyReporter;
var authFailureTracker = auth.AuthFailureTracker;
- var remoteConfigService = new RemoteConfigService(
- backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker);
- var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
-
- // Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
- // downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
- // DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
- // actually respect the tenant admin settings. V1 parity — remote wins
- // unconditionally for every knob that has a 1:1 mapping. CLI flags seed the initial
- // AgentConfiguration in BuildAgentConfiguration and then yield to tenant policy.
- var configMergeResult = RemoteConfigMerger.Merge(agentConfig, remoteConfig, logger);
-
- // Refresh tracker ceilings with the tenant-specific values we just merged in.
- authFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes);
-
- logger.SetLogLevel(agentConfig.LogLevel);
-
- // Propagate the backend-expected SHA so the runtime hash-mismatch trigger (M4.6.α
- // continues this wire; actual runtime trigger will be wired via ServerActionDispatcher
- // in M4.6.β) has the up-to-date integrity hash. Also refresh AllowAgentDowngrade.
- if (!string.IsNullOrEmpty(remoteConfig.LatestAgentSha256))
- SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
-
- // Post-config binary-integrity check: verify the running EXE's SHA-256 against the
- // value advertised by the backend. V1 parity — on mismatch we (a) emit an
- // IntegrityCheckFailed emergency report and (b) fire the runtime self-update trigger
- // so the agent auto-heals (SelfUpdater.CheckAndApplyUpdateAsync force-update path).
- // The trigger is single-shot per process (Interlocked guard inside the verifier).
- var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
- Func runtimeSelfUpdateTrigger = (zipHash, downgrade) =>
- {
- if (!string.IsNullOrEmpty(zipHash))
- SelfUpdater.BackendExpectedSha256 = zipHash;
-
- return SelfUpdater.CheckAndApplyUpdateAsync(
- currentVersion: GetAgentVersion(),
- agentDir: agentDirForTrigger,
- consoleMode: consoleMode,
- forceUpdate: true,
- triggerReason: "runtime_hash_mismatch",
- downloadTimeoutMsOverride: 60000,
- allowDowngrade: downgrade);
- };
-
- var integrityResult = BinaryIntegrityVerifier.Check(
- expectedSha256: remoteConfig.LatestAgentExeSha256,
- logger: logger,
- runtimeSelfUpdateTrigger: runtimeSelfUpdateTrigger,
- zipHash: remoteConfig.LatestAgentSha256,
- allowDowngrade: remoteConfig.AllowAgentDowngrade);
- if (integrityResult.IsMismatch)
- {
- _ = emergencyReporter.TrySendAsync(
- AgentErrorType.IntegrityCheckFailed,
- $"Running exe SHA-256 differs from backend-advertised hash. actual={integrityResult.ActualSha256}, expected={integrityResult.ExpectedSha256}");
- }
-
- // H-2 mitigation: delete the persisted bootstrap-config.json once the MDM cert
- // proves it can authenticate. Non-blocking — any failure leaves the file for retry.
- try
- {
- BootstrapConfigCleanup
- .TryDeleteIfCertReadyAsync(agentConfig, logger, GetAgentVersion())
- .GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}");
- }
+ // Phase 4 (RemoteConfig fetch + Merge + tracker/logger refresh + binary-integrity
+ // verification + bootstrap-config cleanup) is encapsulated in AgentRuntimeConfig.
+ // See Runtime/AgentRuntimeConfig.cs.
+ var runtimeConfig = Runtime.AgentRuntimeConfig.Resolve(agentConfig, auth, GetAgentVersion(), consoleMode, logger);
+ var remoteConfigService = runtimeConfig.RemoteConfigService;
+ var remoteConfig = runtimeConfig.RemoteConfig;
+ var configMergeResult = runtimeConfig.MergeResult;
// Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in
// BackendClientFactory. Construction failures map to V1-parity exit codes
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs
new file mode 100644
index 00000000..4d559906
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs
@@ -0,0 +1,159 @@
+using System;
+using System.IO;
+using System.Reflection;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Runtime
+{
+ ///
+ /// Phase 4 of 's RunAgent: fetch the tenant's
+ /// , merge it into the runtime
+ /// via , refresh tracker
+ /// ceilings + log level, propagate the backend-expected SHA, run the post-config binary-
+ /// integrity check, and best-effort delete the persisted bootstrap-config (H-2 mitigation
+ /// once the MDM cert proves it can authenticate).
+ ///
+ /// No early-exit paths — Phase 4 always returns a populated bundle. Failures inside the
+ /// integrity check or bootstrap-config-cleanup are surfaced as fire-and-forget distress
+ /// reports / log entries; the agent must keep starting on a stale config rather than
+ /// abort, matching V1 behaviour.
+ ///
+ ///
+ internal static class AgentRuntimeConfig
+ {
+ public static RuntimeConfigBundle Resolve(
+ AgentConfiguration agentConfig,
+ BackendAuthBundle auth,
+ string agentVersion,
+ bool consoleMode,
+ 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));
+
+ var remoteConfigService = new RemoteConfigService(
+ auth.BackendApiClient, agentConfig.TenantId, logger,
+ auth.EmergencyReporter, auth.DistressReporter, auth.AuthFailureTracker);
+ var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
+
+ // Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
+ // downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
+ // DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
+ // actually respect the tenant admin settings. V1 parity — remote wins
+ // unconditionally for every knob that has a 1:1 mapping. CLI flags seed the initial
+ // AgentConfiguration in BuildAgentConfiguration and then yield to tenant policy.
+ var configMergeResult = RemoteConfigMerger.Merge(agentConfig, remoteConfig, logger);
+
+ // Refresh tracker ceilings with the tenant-specific values we just merged in.
+ auth.AuthFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes);
+
+ logger.SetLogLevel(agentConfig.LogLevel);
+
+ // Propagate the backend-expected SHA so the runtime hash-mismatch trigger has the
+ // up-to-date integrity hash. Also refresh AllowAgentDowngrade.
+ if (!string.IsNullOrEmpty(remoteConfig.LatestAgentSha256))
+ SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
+
+ VerifyBinaryIntegrity(remoteConfig, auth.EmergencyReporter, agentVersion, consoleMode, logger);
+
+ TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
+
+ return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult);
+ }
+
+ ///
+ /// Post-config binary-integrity check: verify the running EXE's SHA-256 against the
+ /// value advertised by the backend. V1 parity — on mismatch we (a) emit an
+ /// IntegrityCheckFailed emergency report and (b) fire the runtime self-update trigger
+ /// so the agent auto-heals (SelfUpdater.CheckAndApplyUpdateAsync force-update path).
+ /// The trigger is single-shot per process (Interlocked guard inside the verifier).
+ ///
+ private static void VerifyBinaryIntegrity(
+ AgentConfigResponse remoteConfig,
+ EmergencyReporter emergencyReporter,
+ string agentVersion,
+ bool consoleMode,
+ AgentLogger logger)
+ {
+ var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
+ Func runtimeSelfUpdateTrigger = (zipHash, downgrade) =>
+ {
+ if (!string.IsNullOrEmpty(zipHash))
+ SelfUpdater.BackendExpectedSha256 = zipHash;
+
+ return SelfUpdater.CheckAndApplyUpdateAsync(
+ currentVersion: agentVersion,
+ agentDir: agentDirForTrigger,
+ consoleMode: consoleMode,
+ forceUpdate: true,
+ triggerReason: "runtime_hash_mismatch",
+ downloadTimeoutMsOverride: 60000,
+ allowDowngrade: downgrade);
+ };
+
+ var integrityResult = BinaryIntegrityVerifier.Check(
+ expectedSha256: remoteConfig.LatestAgentExeSha256,
+ logger: logger,
+ runtimeSelfUpdateTrigger: runtimeSelfUpdateTrigger,
+ zipHash: remoteConfig.LatestAgentSha256,
+ allowDowngrade: remoteConfig.AllowAgentDowngrade);
+ if (integrityResult.IsMismatch)
+ {
+ _ = emergencyReporter.TrySendAsync(
+ AgentErrorType.IntegrityCheckFailed,
+ $"Running exe SHA-256 differs from backend-advertised hash. actual={integrityResult.ActualSha256}, expected={integrityResult.ExpectedSha256}");
+ }
+ }
+
+ ///
+ /// H-2 mitigation: delete the persisted bootstrap-config.json once the MDM cert
+ /// proves it can authenticate. Non-blocking — any failure leaves the file for retry.
+ ///
+ private static void TryCleanupBootstrapConfig(
+ AgentConfiguration agentConfig,
+ string agentVersion,
+ AgentLogger logger)
+ {
+ try
+ {
+ BootstrapConfigCleanup
+ .TryDeleteIfCertReadyAsync(agentConfig, logger, agentVersion)
+ .GetAwaiter().GetResult();
+ }
+ catch (Exception ex)
+ {
+ logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Phase 4 outcome — the live (used later by the
+ /// rotate_config ServerAction callback), the fetched
+ /// and the diff snapshot (used by the
+ /// EmitUnrestrictedModeAuditIfChanged lifecycle hook).
+ ///
+ internal sealed class RuntimeConfigBundle
+ {
+ public RemoteConfigService RemoteConfigService { get; }
+ public AgentConfigResponse RemoteConfig { get; }
+ public RemoteConfigMergeResult MergeResult { get; }
+
+ public RuntimeConfigBundle(
+ RemoteConfigService remoteConfigService,
+ AgentConfigResponse remoteConfig,
+ RemoteConfigMergeResult mergeResult)
+ {
+ RemoteConfigService = remoteConfigService;
+ RemoteConfig = remoteConfig;
+ MergeResult = mergeResult;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 5b0cffbc..ba300673 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-370
+372
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 8c8b8bca..0eec1ae5 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1179
+1180