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