refactor(v2): extract AgentRuntimeConfig from Program.RunAgent (PR4)

Phase 4 (RemoteConfigService.FetchConfigAsync, RemoteConfigMerger.Merge,
AuthFailureTracker ceiling refresh, logger SetLogLevel, SelfUpdater
hash propagation, BinaryIntegrityVerifier check + IntegrityCheckFailed
emergency-report dispatch, BootstrapConfigCleanup H-2 mitigation) moves
into Runtime/AgentRuntimeConfig.cs.

RuntimeConfigBundle exposes the live RemoteConfigService (still needed
later by the rotate_config ServerAction callback), the fetched
AgentConfigResponse and the RemoteConfigMergeResult diff (consumed by
the EmitUnrestrictedModeAuditIfChanged lifecycle hook).

VerifyBinaryIntegrity and TryCleanupBootstrapConfig stay private to
the factory — they are pure side-effect helpers with no return value
needed downstream. The runtime-self-update trigger closure no longer
captures Program.GetAgentVersion via implicit access; it receives the
agent version as a parameter instead.

RunAgent's Phase 4 block shrinks from ~70 lines to 7 lines (factory
call + bundle destructuring). No behavioural change: ordering of
side effects (Merge before UpdateLimits before SetLogLevel before
SelfUpdater hash before VerifyIntegrity before BootstrapCleanup) is
preserved exactly.

Tests: 4 new AgentRuntimeConfigTests (RuntimeConfigBundle property
mapping + 3 null-arg validation cases). Full V2.Core suite 753/754
(1 known RecoveryPathTests threading flake, passes solo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-26 11:57:23 +02:00
parent 1785854ac7
commit 6b804d38d9
5 changed files with 258 additions and 71 deletions
@@ -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
{
/// <summary>
/// Tests for <see cref="AgentRuntimeConfig"/>: Phase 4 extract from <c>Program.RunAgent</c>.
/// 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).
/// </summary>
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<ArgumentNullException>(
() => 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<ArgumentNullException>(
() => 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<ArgumentNullException>(
() => AgentRuntimeConfig.Resolve(MinimalConfig(), auth, "1.0", consoleMode: false, logger: null));
}
}
}
+7 -69
View File
@@ -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<string, bool, Task> 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
@@ -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
{
/// <summary>
/// Phase 4 of <see cref="Program"/>'s <c>RunAgent</c>: fetch the tenant's
/// <see cref="AgentConfigResponse"/>, merge it into the runtime
/// <see cref="AgentConfiguration"/> via <see cref="RemoteConfigMerger"/>, 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).
/// <para>
/// 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
private static void VerifyBinaryIntegrity(
AgentConfigResponse remoteConfig,
EmergencyReporter emergencyReporter,
string agentVersion,
bool consoleMode,
AgentLogger logger)
{
var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
Func<string, bool, Task> 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}");
}
}
/// <summary>
/// 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.
/// </summary>
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}");
}
}
}
/// <summary>
/// Phase 4 outcome — the live <see cref="RemoteConfigService"/> (used later by the
/// <c>rotate_config</c> ServerAction callback), the fetched <see cref="AgentConfigResponse"/>
/// and the <see cref="RemoteConfigMergeResult"/> diff snapshot (used by the
/// <c>EmitUnrestrictedModeAuditIfChanged</c> lifecycle hook).
/// </summary>
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;
}
}
}
@@ -1 +1 @@
370
372
@@ -1 +1 @@
1179
1180