mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user