diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs index 56eb6b15..4f9bb69c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Xunit; @@ -9,8 +10,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration { /// /// Tests for . This merger is the single source of - /// Legacy-parity: every field delivered by the backend that has a live V2 consumer must - /// land on after FetchConfigAsync. + /// V1-parity: every field delivered by the backend that has a live V2 consumer must land + /// on after FetchConfigAsync. + /// + /// V1 parity: remote values OVERWRITE the runtime configuration unconditionally. CLI + /// flags only seed the initial and yield to tenant + /// policy once Merge runs. This is asserted by a regression test below. + /// /// public sealed class RemoteConfigMergerTests { @@ -46,6 +52,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration LogLevel = "Debug", SendTraceEvents = false, UnrestrictedMode = true, + MaxAuthFailures = 7, + AuthFailureTimeoutMinutes = 15, + EnableImeMatchLog = true, Collectors = new CollectorConfiguration { AgentMaxLifetimeMinutes = 120, @@ -56,12 +65,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration // ============================================================= Happy path [Fact] - public void Merge_applies_all_tenant_fields_when_no_cli_overrides() + public void Merge_applies_all_tenant_fields() { var agent = NewAgentConfig(); var remote = FullRemote(); - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + var result = RemoteConfigMerger.Merge(agent, remote); Assert.False(agent.SelfDestructOnComplete); Assert.True(agent.KeepLogFile); @@ -85,40 +94,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Equal(250, agent.MaxBatchSize); Assert.Equal(120, agent.AgentMaxLifetimeMinutes); Assert.Equal(45, agent.HelloWaitTimeoutSeconds); + Assert.Equal(7, agent.MaxAuthFailures); + Assert.Equal(15, agent.AuthFailureTimeoutMinutes); + + Assert.NotNull(result); + Assert.True(result.UnrestrictedModeChanged); + Assert.False(result.OldUnrestrictedMode); + Assert.True(result.NewUnrestrictedMode); + Assert.True(result.LogLevelChanged); + Assert.Equal(AgentLogLevel.Info, result.OldLogLevel); + Assert.Equal(AgentLogLevel.Debug, result.NewLogLevel); } - // ============================================================= CLI override wins + // ============================================================= Remote-wins-always (V1 parity) [Fact] - public void Merge_preserves_no_cleanup_cli_flag_over_remote_true() + public void Merge_remote_wins_over_prior_agent_self_destruct() { + // BuildAgentConfiguration may have set SelfDestructOnComplete from the --no-cleanup CLI + // flag. Once remote config arrives it takes over — V1 behaviour (operator cannot + // override tenant policy from the command line). var agent = NewAgentConfig(); - agent.SelfDestructOnComplete = false; // as set by BuildAgentConfiguration when CLI had --no-cleanup + agent.SelfDestructOnComplete = false; var remote = FullRemote(); - remote.SelfDestructOnComplete = true; // tenant enabled cleanup, dev disabled locally + remote.SelfDestructOnComplete = true; - RemoteConfigMerger.Merge(agent, remote, new[] { "--no-cleanup" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.False(agent.SelfDestructOnComplete); + Assert.True(agent.SelfDestructOnComplete); } [Fact] - public void Merge_preserves_keep_logfile_cli_flag_over_remote_false() - { - var agent = NewAgentConfig(); - agent.KeepLogFile = true; - - var remote = FullRemote(); - remote.KeepLogFile = false; - - RemoteConfigMerger.Merge(agent, remote, new[] { "--keep-logfile" }); - - Assert.True(agent.KeepLogFile); - } - - [Fact] - public void Merge_preserves_reboot_cli_flag_over_remote_false() + public void Merge_remote_wins_over_prior_agent_reboot_on_complete() { var agent = NewAgentConfig(); agent.RebootOnComplete = true; @@ -126,27 +134,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.RebootOnComplete = false; - RemoteConfigMerger.Merge(agent, remote, new[] { "--reboot-on-complete" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.True(agent.RebootOnComplete); + Assert.False(agent.RebootOnComplete); } [Fact] - public void Merge_preserves_disable_geolocation_cli_flag_over_remote_true() - { - var agent = NewAgentConfig(); - agent.EnableGeoLocation = false; - - var remote = FullRemote(); - remote.EnableGeoLocation = true; - - RemoteConfigMerger.Merge(agent, remote, new[] { "--disable-geolocation" }); - - Assert.False(agent.EnableGeoLocation); - } - - [Fact] - public void Merge_preserves_log_level_cli_arg_over_remote_string() + public void Merge_remote_wins_over_prior_agent_log_level() { var agent = NewAgentConfig(); agent.LogLevel = AgentLogLevel.Verbose; @@ -154,9 +148,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "Debug"; - RemoteConfigMerger.Merge(agent, remote, new[] { "--log-level", "Verbose" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.Equal(AgentLogLevel.Verbose, agent.LogLevel); + Assert.Equal(AgentLogLevel.Debug, agent.LogLevel); } [Fact] @@ -166,7 +160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "trace"; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(AgentLogLevel.Trace, agent.LogLevel); } @@ -180,11 +174,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "NotAValidLevel"; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(AgentLogLevel.Info, agent.LogLevel); } + [Fact] + public void Merge_remote_null_ntp_server_clears_prior_value() + { + // V1 parity: remote null overwrites local without a IsNullOrWhiteSpace gate. + var agent = NewAgentConfig(); + agent.NtpServer = "local.ntp"; + + var remote = FullRemote(); + remote.NtpServer = null!; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Null(agent.NtpServer); + } + + [Fact] + public void Merge_remote_off_overrides_prior_diagnostics_upload_mode() + { + // Tenant disables diagnostics upload — must take effect even when the agent was + // previously configured (e.g. by a prior config fetch cached on disk) to something + // else. V1 parity. + var agent = NewAgentConfig(); + agent.DiagnosticsUploadMode = "OnFailure"; + agent.DiagnosticsUploadEnabled = true; + + var remote = FullRemote(); + remote.DiagnosticsUploadMode = "Off"; + remote.DiagnosticsUploadEnabled = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal("Off", agent.DiagnosticsUploadMode); + Assert.False(agent.DiagnosticsUploadEnabled); + } + + [Fact] + public void Merge_unrestricted_mode_change_is_reported_via_result() + { + var agent = NewAgentConfig(); + agent.UnrestrictedMode = false; + + var remote = FullRemote(); + remote.UnrestrictedMode = true; + + var result = RemoteConfigMerger.Merge(agent, remote); + + Assert.True(result.UnrestrictedModeChanged); + Assert.False(result.OldUnrestrictedMode); + Assert.True(result.NewUnrestrictedMode); + Assert.True(agent.UnrestrictedMode); + } + + [Fact] + public void Merge_unrestricted_mode_stable_is_not_reported_as_change() + { + var agent = NewAgentConfig(); + agent.UnrestrictedMode = true; + + var remote = FullRemote(); + remote.UnrestrictedMode = true; + + var result = RemoteConfigMerger.Merge(agent, remote); + + Assert.False(result.UnrestrictedModeChanged); + } + + [Fact] + public void Merge_maps_enable_ime_match_log_true_to_default_path() + { + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = null; + + var remote = FullRemote(); + remote.EnableImeMatchLog = true; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.False(string.IsNullOrEmpty(agent.ImeMatchLogPath)); + Assert.Equal( + Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath), + agent.ImeMatchLogPath); + } + + [Fact] + public void Merge_maps_enable_ime_match_log_false_clears_path() + { + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + + var remote = FullRemote(); + remote.EnableImeMatchLog = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Null(agent.ImeMatchLogPath); + } + + [Fact] + public void Merge_preserves_custom_ime_match_log_path_when_remote_disables() + { + // A dev-time --ime-match-log override seeds a non-default path on the + // AgentConfiguration. When the tenant remote flips EnableImeMatchLog off we keep + // the custom path rather than clobbering a deliberate dev override. + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = @"C:\Temp\custom-ime.log"; + + var remote = FullRemote(); + remote.EnableImeMatchLog = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath); + } + // ============================================================= Null / partial tolerance [Fact] @@ -194,22 +302,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration agent.NtpServer = "local.ntp"; agent.SelfDestructOnComplete = true; - RemoteConfigMerger.Merge(agent, null!, Array.Empty()); + var result = RemoteConfigMerger.Merge(agent, null!); Assert.Equal("local.ntp", agent.NtpServer); Assert.True(agent.SelfDestructOnComplete); - } - - [Fact] - public void Merge_accepts_null_cli_args() - { - var agent = NewAgentConfig(); - var remote = FullRemote(); - - RemoteConfigMerger.Merge(agent, remote, null!); - - Assert.False(agent.SelfDestructOnComplete); - Assert.Equal("pool.ntp.org", agent.NtpServer); + Assert.NotNull(result); + Assert.False(result.UnrestrictedModeChanged); } [Fact] @@ -222,40 +320,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.Collectors = null!; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(360, agent.AgentMaxLifetimeMinutes); Assert.Equal(30, agent.HelloWaitTimeoutSeconds); } - [Fact] - public void Merge_keeps_default_ntp_server_when_remote_is_empty() - { - var agent = NewAgentConfig(); - agent.NtpServer = "time.windows.com"; - - var remote = FullRemote(); - remote.NtpServer = ""; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal("time.windows.com", agent.NtpServer); - } - - [Fact] - public void Merge_keeps_default_diagnostics_mode_when_remote_is_null() - { - var agent = NewAgentConfig(); - agent.DiagnosticsUploadMode = "Off"; - - var remote = FullRemote(); - remote.DiagnosticsUploadMode = null!; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal("Off", agent.DiagnosticsUploadMode); - } - [Fact] public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null() { @@ -268,7 +338,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.DiagnosticsLogPaths = null!; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.NotNull(agent.DiagnosticsLogPaths); Assert.Empty(agent.DiagnosticsLogPaths); @@ -276,20 +346,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration // ============================================================= Int clamping - [Fact] - public void Merge_skips_non_positive_upload_interval() - { - var agent = NewAgentConfig(); - agent.UploadIntervalSeconds = 30; - - var remote = FullRemote(); - remote.UploadIntervalSeconds = 0; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal(30, agent.UploadIntervalSeconds); - } - [Fact] public void Merge_skips_non_positive_max_batch_size() { @@ -299,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.MaxBatchSize = -1; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(100, agent.MaxBatchSize); } @@ -307,6 +363,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration [Fact] public void Merge_skips_non_positive_hello_wait_timeout() { + // V1 parity: MonitoringService.ApplyRuntimeSettingsFromRemoteConfig only applies the + // remote value when > 0 so an accidentally misconfigured tenant does not drop the + // Hello-wait to zero. var agent = NewAgentConfig(); agent.HelloWaitTimeoutSeconds = 30; @@ -317,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration HelloWaitTimeoutSeconds = 0, }; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(30, agent.HelloWaitTimeoutSeconds); } @@ -335,7 +394,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration HelloWaitTimeoutSeconds = 30, }; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(0, agent.AgentMaxLifetimeMinutes); } @@ -346,26 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration public void Merge_throws_on_null_agent_config() { Assert.Throws(() => - RemoteConfigMerger.Merge(null!, FullRemote(), Array.Empty())); - } - - // ============================================================= The user's scenario - - [Fact] - public void Merge_flips_self_destruct_off_when_tenant_admin_disables_cleanup_and_agent_runs_without_cli_args() - { - // Regression guard for the VM smoke-test bug: tenant config has SelfDestructOnComplete=false - // but agent was built with CLI-default true, and without the merger the remote value never - // reached CleanupService / EnrollmentTerminationHandler. - var agent = NewAgentConfig(); - agent.SelfDestructOnComplete = true; // BuildAgentConfiguration CLI-default when no --no-cleanup - - var remote = FullRemote(); - remote.SelfDestructOnComplete = false; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.False(agent.SelfDestructOnComplete); + RemoteConfigMerger.Merge(null!, FullRemote())); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs index f168e5a2..8b87e372 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs @@ -1,8 +1,12 @@ using System; +using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Security @@ -226,6 +230,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security new AuthFailureTracker(1, 0, new FakeClock(DateTime.UtcNow), null!)); } + // ============================================================= Distress dispatch (V1 parity) + + [Fact] + public void RecordFailure_dispatches_distress_only_on_first_failure_for_401() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(401, "agent/config"); + tracker.RecordFailure(401, "agent/config"); + tracker.RecordFailure(401, "agent/config"); + + Assert.Single(fakeDistress.Calls); + Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType); + Assert.Equal(401, fakeDistress.Calls[0].HttpStatusCode); + Assert.Contains("agent/config", fakeDistress.Calls[0].Message); + } + + [Fact] + public void RecordFailure_dispatches_device_not_registered_for_403() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(403, "agent/telemetry"); + + Assert.Single(fakeDistress.Calls); + Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType); + Assert.Equal(403, fakeDistress.Calls[0].HttpStatusCode); + } + + [Fact] + public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger()); + + int events = 0; + tracker.ThresholdExceeded += (_, _) => events++; + + tracker.RecordFailure(401, "op"); + tracker.RecordFailure(401, "op"); + + Assert.Equal(1, events); + } + + private sealed class FakeDistressReporter : DistressReporter + { + public sealed class Call + { + public DistressErrorType ErrorType { get; set; } + public string Message { get; set; } = default!; + public int? HttpStatusCode { get; set; } + } + + public List Calls { get; } = new List(); + + public FakeDistressReporter() + : base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!) + { + } + + public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null) + { + Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode }); + return Task.CompletedTask; + } + } + private sealed class FakeClock : AutopilotMonitor.DecisionCore.Engine.IClock { public DateTime Now { get; set; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs index 2bd59ded..24ce8897 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs @@ -1,4 +1,7 @@ +using System; using System.IO; +using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; @@ -96,5 +99,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash); } + + [Fact] + public void Check_match_does_not_invoke_runtime_self_update_trigger() + { + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x01, 0x02 }); + var actual = BinaryIntegrityVerifier.ComputeSha256Hex(exe); + + var triggerCount = 0; + Func trigger = (_, __) => + { + Interlocked.Increment(ref triggerCount); + return Task.CompletedTask; + }; + + var result = BinaryIntegrityVerifier.Check(actual, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip", allowDowngrade: false); + + Assert.Equal(IntegrityCheckOutcome.Match, result.Outcome); + Assert.Equal(0, Volatile.Read(ref triggerCount)); + } + + [Fact] + public void Check_mismatch_fires_runtime_self_update_trigger_exactly_once() + { + // V1 parity + single-shot guard: repeat calls at runtime (e.g. config-rotation hash + // drift) must not fan out into multiple force-update attempts. + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x01 }); + var wrongHash = new string('f', 64); + + var triggerCount = 0; + string capturedZipHash = null; + bool capturedDowngrade = false; + var triggerDone = new ManualResetEventSlim(false); + Func trigger = (zip, down) => + { + Interlocked.Increment(ref triggerCount); + capturedZipHash = zip; + capturedDowngrade = down; + triggerDone.Set(); + return Task.CompletedTask; + }; + + var r1 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true); + var r2 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true); + + Assert.Equal(IntegrityCheckOutcome.Mismatch, r1.Outcome); + Assert.Equal(IntegrityCheckOutcome.Mismatch, r2.Outcome); + + Assert.True(triggerDone.Wait(TimeSpan.FromSeconds(10)), "trigger did not run within 10s"); + Assert.Equal(1, Volatile.Read(ref triggerCount)); + Assert.Equal("zip-hash", capturedZipHash); + Assert.True(capturedDowngrade); + } + + [Fact] + public void Check_mismatch_without_trigger_only_logs() + { + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x07 }); + var wrongHash = new string('e', 64); + + var result = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: null); + + Assert.Equal(IntegrityCheckOutcome.Mismatch, result.Outcome); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index e65c5168..6696e6c2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,9 +9,11 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination @@ -44,6 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination public string? LastDiagnosticsSuffix { get; private set; } public DiagnosticsUploadResult? DiagnosticsResult { get; set; } public int ShutdownSignalled; + public List EmittedEvents { get; } = new List(); + public int RebootInvocations; + public int RebootDelaySeconds; + public SessionIdPersistence SessionPersistence { get; set; } = default!; public Rig() { @@ -51,6 +58,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); Packages = new AppPackageStateList(Logger); CleanupService = new RecordingCleanupService(BuildConfig(), Logger); + SessionPersistence = new SessionIdPersistence(Tmp.Path); } public AgentConfiguration BuildConfig( @@ -90,7 +98,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination LastDiagnosticsSuffix = suffix; return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" }); }, - signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled)); + signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled), + analyzerManager: null, + emitEvent: evt => EmittedEvents.Add(evt), + sessionPersistence: SessionPersistence, + triggerReboot: delay => + { + Interlocked.Increment(ref RebootInvocations); + RebootDelaySeconds = delay; + }, + // Zero out the timing ceremony for tests — production paths are covered by + // the dedicated V1-parity tests below which opt back in via their own Rig. + lateEventGracePeriod: TimeSpan.Zero, + spoolDrainPeriod: TimeSpan.Zero); } public void Dispose() => Tmp.Dispose(); @@ -217,5 +237,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination Assert.Equal("failure", rig.LastDiagnosticsSuffix); Assert.Equal(false, rig.LastDiagnosticsSucceededFlag); } + + // ============================================================= PR #50 lifecycle events + + [Fact] + public void Handle_emits_diagnostics_collecting_and_uploaded_events_on_success() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip", SasUrlPrefix = "https://example.test" }; + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting"); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_uploaded" && + ((string)e.Data["blobName"]) == "diag-blob.zip"); + Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed"); + } + + [Fact] + public void Handle_emits_diagnostics_upload_failed_when_upload_returns_null() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + rig.DiagnosticsResult = new DiagnosticsUploadResult { ErrorCode = "upload_5xx" }; + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting"); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed" && + ((string)e.Data["errorCode"]) == "upload_5xx"); + } + + [Fact] + public void Handle_whiteglove_part1_writes_marker_and_emits_whiteglove_part1_complete() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed)); + + // The whiteglove.complete marker must be present so the next boot classifies as Part 2 resume. + Assert.True(rig.SessionPersistence.IsWhiteGloveResume()); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "whiteglove_part1_complete"); + // And — critically — self-destruct MUST have been skipped and the enrollment-complete + // marker MUST NOT be written, or the next Part-2 boot would ghost-detect itself. + Assert.Equal(0, rig.CleanupService.Invocations); + Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + } + + [Fact] + public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: false); + cfg.RebootOnComplete = true; + cfg.RebootDelaySeconds = 15; + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(1, rig.RebootInvocations); + Assert.Equal(15, rig.RebootDelaySeconds); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "reboot_triggered"); + } + + [Fact] + public void Handle_does_not_reboot_when_self_destruct_is_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: true); + cfg.RebootOnComplete = true; + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + // Self-destruct owns the terminal transition; reboot is the no-self-destruct path only. + Assert.Equal(0, rig.RebootInvocations); + Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "reboot_triggered"); + } + + [Fact] + public void Handle_emits_enrollment_summary_shown_when_dialog_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(showDialog: true, selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "enrollment_summary_shown"); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs index 4aed3f5e..e55b0dc0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Configuration @@ -17,100 +18,160 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration /// to defaults set from CLI / bootstrap-config, so tenant /// admin settings were effectively ignored. /// - /// CLI-override semantics: command-line flags that were explicitly passed take precedence - /// over the remote value. In production (Scheduled Task run with no args) the remote value - /// always wins; in local dev runs the developer can still force a specific behaviour with - /// a CLI flag without their tenant remote config clobbering it. + /// Merge semantics (V1 parity): remote values OVERWRITE the runtime configuration + /// unconditionally (V1 ApplyRuntimeSettingsFromRemoteConfig). CLI flags seed the + /// initial before Merge runs and then yield to tenant + /// policy — if an operator deploys with --reboot-on-complete but the tenant config + /// disables reboots, the tenant wins. This matches the V1 behaviour and keeps tenants as + /// the source of truth for the knobs that have a remote equivalent. /// /// public static class RemoteConfigMerger { /// /// Overwrites the relevant fields with values from - /// . Unknown / null / empty remote fields keep the - /// current value (partial-remote-config tolerance). + /// . Remote wins unconditionally for every field that has + /// a direct 1:1 mapping. Returns a describing which + /// observable knobs changed so the caller can emit audit events (e.g. + /// agent_unrestricted_mode_changed) after the orchestrator is running. /// - public static void Merge( + public static RemoteConfigMergeResult Merge( AgentConfiguration agentConfig, AgentConfigResponse remoteConfig, - string[] cliArgs) + AgentLogger logger = null) { if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); - if (remoteConfig == null) return; - cliArgs = cliArgs ?? Array.Empty(); + if (remoteConfig == null) return RemoteConfigMergeResult.NoChange(); - // ---- Boolean flags that have a matching CLI override — CLI wins when present. - if (!ContainsFlag(cliArgs, "--no-cleanup")) - agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete; - if (!ContainsFlag(cliArgs, "--keep-logfile")) - agentConfig.KeepLogFile = remoteConfig.KeepLogFile; - if (!ContainsFlag(cliArgs, "--reboot-on-complete")) - agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete; - if (!ContainsFlag(cliArgs, "--disable-geolocation")) - agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation; + var result = new RemoteConfigMergeResult(); - // ---- Log level — CLI `--log-level ` wins. Remote is a string that may not parse. - if (!ContainsValueArg(cliArgs, "--log-level") - && !string.IsNullOrWhiteSpace(remoteConfig.LogLevel) - && Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel)) + // ---------------- Simple scalar flags — remote wins. + agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete; + agentConfig.KeepLogFile = remoteConfig.KeepLogFile; + agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete; + agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation; + + // ---------------- Log level — remote wins, but we parse defensively so an invalid + // remote value keeps the current level (and logs a warning). + result.OldLogLevel = agentConfig.LogLevel; + if (!string.IsNullOrWhiteSpace(remoteConfig.LogLevel)) { - agentConfig.LogLevel = parsedLevel; + if (Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel)) + { + if (parsedLevel != agentConfig.LogLevel) + { + logger?.Info($"Remote config: LogLevel {agentConfig.LogLevel} → {parsedLevel}"); + agentConfig.LogLevel = parsedLevel; + } + } + else + { + logger?.Warning($"Remote config: LogLevel '{remoteConfig.LogLevel}' is not a valid AgentLogLevel — keeping {agentConfig.LogLevel}."); + } } + result.NewLogLevel = agentConfig.LogLevel; + result.LogLevelChanged = result.OldLogLevel != result.NewLogLevel; - // ---- Fields without a CLI override — always apply remote. + // ---------------- Reboot / summary-dialog knobs — direct assignment. agentConfig.RebootDelaySeconds = remoteConfig.RebootDelaySeconds; agentConfig.ShowEnrollmentSummary = remoteConfig.ShowEnrollmentSummary; agentConfig.EnrollmentSummaryTimeoutSeconds = remoteConfig.EnrollmentSummaryTimeoutSeconds; agentConfig.EnrollmentSummaryBrandingImageUrl = remoteConfig.EnrollmentSummaryBrandingImageUrl; agentConfig.EnrollmentSummaryLaunchRetrySeconds = remoteConfig.EnrollmentSummaryLaunchRetrySeconds; - if (!string.IsNullOrWhiteSpace(remoteConfig.NtpServer)) - agentConfig.NtpServer = remoteConfig.NtpServer; + // ---------------- NTP / Timezone — remote wins even when the remote sends null so + // a tenant admin can clear a previously-applied custom NTP server. + agentConfig.NtpServer = remoteConfig.NtpServer; agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet; + // ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off" + // which must clear any CLI-initial default). agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled; - if (!string.IsNullOrWhiteSpace(remoteConfig.DiagnosticsUploadMode)) - agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode; + agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode; agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List(); agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents; - agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode; - // ---- Positive-only int knobs — a zero or negative remote value keeps the current default. - if (remoteConfig.UploadIntervalSeconds > 0) - agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds; + // ---------------- UnrestrictedMode audit — track old/new for the caller so the + // agent_unrestricted_mode_changed event can fire AFTER the + // orchestrator emitter is alive (V1 parity with + // AuditUnrestrictedModeChange in MonitoringService.cs). + result.OldUnrestrictedMode = agentConfig.UnrestrictedMode; + agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode; + result.NewUnrestrictedMode = agentConfig.UnrestrictedMode; + result.UnrestrictedModeChanged = result.OldUnrestrictedMode != result.NewUnrestrictedMode; + + // ---------------- ImeMatchLog: remote ships a bool, V2 runtime uses a file path. + // V1 parity — `true` expands Constants.ImeMatchLogPath, `false` + // nulls the runtime path so the collector disables itself. + // A CLI override (--ime-match-log ) already populated + // ImeMatchLogPath in BuildAgentConfiguration; we only clobber it + // when the remote bool flips off, mirroring V1's unconditional + // assignment with tolerance for the explicit dev override. + if (remoteConfig.EnableImeMatchLog) + { + agentConfig.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + } + else + { + // Only null the path when the user did NOT supply an explicit CLI override. + // A non-null value that does not match the default is a dev-time override. + var defaultPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + if (string.IsNullOrEmpty(agentConfig.ImeMatchLogPath) + || string.Equals(agentConfig.ImeMatchLogPath, defaultPath, StringComparison.OrdinalIgnoreCase)) + { + agentConfig.ImeMatchLogPath = null; + } + } + + // ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a + // misconfigured tenant can in theory break uploads, but this + // surfaces the config bug instead of silently masking it). + agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds; if (remoteConfig.MaxBatchSize > 0) agentConfig.MaxBatchSize = remoteConfig.MaxBatchSize; - // ---- Nested: CollectorConfiguration. Only the fields that have a V2 consumer - // on AgentConfiguration are projected here; the rest flow directly from - // remoteConfig.Collectors via DefaultComponentFactory. + // ---------------- AuthFailure ceilings — tracker.UpdateLimits() called by caller + // so the refresh lands on the live watchdog. + agentConfig.MaxAuthFailures = remoteConfig.MaxAuthFailures; + agentConfig.AuthFailureTimeoutMinutes = remoteConfig.AuthFailureTimeoutMinutes; + + // ---------------- Nested CollectorConfiguration — only the two knobs that have a + // V2 consumer on AgentConfiguration itself. The rest flow through + // the untouched remoteConfig.Collectors to DefaultComponentFactory. var collectors = remoteConfig.Collectors; if (collectors != null) { // AgentMaxLifetimeMinutes: 0 = disabled (valid); passed to the orchestrator as a TimeSpan?. agentConfig.AgentMaxLifetimeMinutes = collectors.AgentMaxLifetimeMinutes; + + // HelloWaitTimeoutSeconds: V1 parity — only apply when > 0 so an accidentally + // zero/negative remote value keeps the current non-zero default. V1: + // MonitoringService.ApplyRuntimeSettingsFromRemoteConfig preserves the current + // setting instead of disabling Hello-wait altogether. if (collectors.HelloWaitTimeoutSeconds > 0) agentConfig.HelloWaitTimeoutSeconds = collectors.HelloWaitTimeoutSeconds; } - } - private static bool ContainsFlag(string[] args, string flag) - { - for (int i = 0; i < args.Length; i++) - if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) - return true; - return false; - } - - private static bool ContainsValueArg(string[] args, string flag) - { - // Value-args need both the flag and a subsequent token, otherwise the CLI helper would - // not have parsed them — mirror GetArgValue's "i < args.Length - 1" contract. - for (int i = 0; i < args.Length - 1; i++) - if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) - return true; - return false; + return result; } } + + /// + /// Observable side-effects of . The caller uses this + /// to decide whether to emit audit events / reconfigure live components (logger, watchdog) + /// after the orchestrator is alive. + /// + public sealed class RemoteConfigMergeResult + { + public bool UnrestrictedModeChanged { get; set; } + public bool OldUnrestrictedMode { get; set; } + public bool NewUnrestrictedMode { get; set; } + + public bool LogLevelChanged { get; set; } + public AgentLogLevel OldLogLevel { get; set; } + public AgentLogLevel NewLogLevel { get; set; } + + public static RemoteConfigMergeResult NoChange() => new RemoteConfigMergeResult(); + } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs index 7915daa2..d5770b01 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs @@ -83,18 +83,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration { _logger.Warning($"Config fetch authentication failed: {ex.Message}"); - // Auth failure → use distress channel (pre-auth, works without cert) - if (_distressReporter != null) - { - _ = _distressReporter.TrySendAsync( - DistressErrorType.ConfigFetchDenied, - ex.Message, - httpStatusCode: ex.StatusCode); - } - - // Feed the central auth-failure tracker so the agent can shut down cleanly - // after MaxAuthFailures / AuthFailureTimeoutMinutes is exceeded, instead of - // hammering the backend forever when the device certificate is revoked. + // V1 parity — AuthFailureTracker is the single dispatch point for auth-failure + // distress signals (see HandleAuthFailure in EventUploadOrchestrator). It sends + // the first-failure report via its constructor-injected DistressReporter with + // the correct DistressErrorType per status code (401 → AuthCertificateRejected, + // 403 → DeviceNotRegistered) and suppresses duplicates on subsequent failures. _authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config"); } catch (Exception ex) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index fbd80b40..ee087f5f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -192,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration onEnrollmentEvent: onEnrollmentEvent, logger: logger, rules: _remoteConfig.GatherRules, - imeLogPathOverride: _agentConfig.ImeLogPathOverride)); + imeLogPathOverride: _agentConfig.ImeLogPathOverride, + unrestrictedMode: _agentConfig.UnrestrictedMode)); } return hosts; @@ -209,6 +210,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly Monitoring.Telemetry.Gather.GatherRuleExecutor _executor; private readonly System.Collections.Generic.List _rules; private readonly AgentLogger _logger; + private readonly bool _unrestrictedMode; private int _disposed; public GatherRuleExecutorHost( @@ -217,18 +219,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration Action onEnrollmentEvent, AgentLogger logger, System.Collections.Generic.List rules, - string? imeLogPathOverride) + string? imeLogPathOverride, + bool unrestrictedMode = false) { _logger = logger; _rules = rules ?? new System.Collections.Generic.List(); + _unrestrictedMode = unrestrictedMode; _executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor( sessionId, tenantId, onEnrollmentEvent, logger, imeLogPathOverride); } public void Start() { + // V1 parity (CollectorCoordinator.StartGatherRuleExecutor) — propagate the + // tenant-controlled UnrestrictedMode BEFORE UpdateRules so any startup-trigger + // rule sees the elevated policy when AllowList checks would otherwise reject it. + _executor.UnrestrictedMode = _unrestrictedMode; _executor.UpdateRules(_rules); - _logger.Info($"GatherRuleExecutorHost: started with {_rules.Count} rule(s)."); + _logger.Info( + $"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}."); } public void Stop() diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs index 801a8e5e..cfa07083 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs @@ -1,7 +1,9 @@ using System; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Security { @@ -18,6 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// AgentConfigResponse (defaults: 5 consecutive, time window disabled). /// /// + /// V1 parity (EventUploadOrchestrator.HandleAuthFailure): the tracker is the single + /// dispatch point for distress reports. The first failure emits a distress signal + /// via the optional with + /// for 401 and + /// for 403. Subsequent failures are + /// logged but do not spam the distress channel — there is no point asking the backend + /// twice about a certificate it already rejected. + /// + /// /// Thread-safety: consecutive-count uses ; the first-failure timestamp /// is protected by a single monitor lock. is raised outside the /// lock to prevent handler re-entrancy deadlocks. @@ -27,6 +38,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { private readonly IClock _clock; private readonly AgentLogger _logger; + private readonly DistressReporter _distressReporter; private readonly object _windowLock = new object(); private int _maxFailures; // 0 = disabled @@ -39,10 +51,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security int maxFailures, int timeoutMinutes, IClock clock, - AgentLogger logger) + AgentLogger logger, + DistressReporter distressReporter = null) { _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _distressReporter = distressReporter; UpdateLimits(maxFailures, timeoutMinutes); } @@ -67,8 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } /// - /// Record a 401/403 from the given . Emits - /// the first time either ceiling is crossed; subsequent calls after termination is armed are no-ops. + /// Record a 401/403 from the given . V1 parity — on the FIRST + /// failure this fires a single distress report via the constructor-injected + /// (401 → AuthCertificateRejected, 403 → DeviceNotRegistered). + /// Subsequent failures are logged + counted but do not dispatch further distress signals. + /// Emits the first time either ceiling is crossed; + /// subsequent calls after termination is armed are no-ops. /// public void RecordFailure(int statusCode, string operation) { @@ -84,12 +102,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Security firstFailureAt = _firstFailureUtc.Value; } + // V1 parity — only the first consecutive failure produces a distress report. Repeat + // hits at 2..N use the local log only; the backend already knows from the first + // report that the device is unauthorized. + if (count == 1 && _distressReporter != null) + { + var distressType = statusCode == 403 + ? DistressErrorType.DeviceNotRegistered + : DistressErrorType.AuthCertificateRejected; + try + { + _ = _distressReporter.TrySendAsync( + distressType, + $"Backend returned {statusCode} during {operation}", + httpStatusCode: statusCode); + } + catch (Exception ex) + { + _logger.Debug($"AuthFailureTracker: distress dispatch threw: {ex.Message}"); + } + } + bool countExceeded = _maxFailures > 0 && count >= _maxFailures; bool windowExceeded = _timeoutWindow.HasValue && (now - firstFailureAt) >= _timeoutWindow.Value; if (!countExceeded && !windowExceeded) { - _logger.Warning($"AuthFailureTracker: consecutive auth failure #{count} (http {statusCode}, {operation})."); + _logger.Warning( + $"Authentication failure {count}/{_maxFailures} (first failure at {firstFailureAt:HH:mm:ss}, " + + $"http {statusCode}, {operation})."); return; } @@ -99,7 +140,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security ? $"consecutive auth failures reached limit ({count} >= {_maxFailures})" : $"auth-failure time window exceeded ({(now - firstFailureAt).TotalMinutes:F0}min >= {_timeoutWindow.Value.TotalMinutes:F0}min)"; - _logger.Error($"AuthFailureTracker: {reason} — signalling shutdown. Last operation: {operation}, statusCode={statusCode}."); + _logger.Error( + $"=== AGENT SHUTDOWN: {count} consecutive authentication failures (401/403) — " + + "backend rejecting requests. Terminating agent to prevent distress spam. ==="); + _logger.Error($"AuthFailureTracker: {reason}. Last operation: {operation}, statusCode={statusCode}."); try { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs index 202fe1a4..a8794ec4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs @@ -1,8 +1,10 @@ using System; +using System.Diagnostics; using System.IO; -using System.Reflection; using System.Security.Cryptography; using System.Text; +using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; namespace AutopilotMonitor.Agent.V2.Core.Security @@ -17,35 +19,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// self-update failed silently — a previous update swapped the ZIP but not the EXE. /// /// - /// We intentionally do not terminate or auto-update from this check: - /// already runs at startup and its hash-based trigger covers the "wrong EXE" case. All we do - /// here is emit an AgentErrorType.IntegrityCheckFailed report so operators see the drift. + /// V1 parity: on mismatch an / AgentErrorType.IntegrityCheckFailed + /// report is emitted by the caller, and — if is wired — + /// is invoked in force-update mode to auto-heal. + /// The trigger is single-shot per agent process ( guard) so a noisy + /// config fetch cannot drive the update pipeline multiple times in a row. /// /// public static class BinaryIntegrityVerifier { + private static int _runtimeSelfUpdateFired; + /// - /// Computes the SHA-256 of (or the currently executing assembly - /// when null) and compares it against . + /// Computes the SHA-256 of (or the current process' main-module + /// image when null) and compares it against . /// Case-insensitive comparison (both lowercase and uppercase hex strings accepted). + /// + /// When is non-null and the hashes mismatch, + /// fires the trigger once per agent process — typically wired to + /// SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate: true, triggerReason: "runtime_hash_mismatch"). + /// /// + /// Backend-advertised SHA-256 (lowercase hex preferred). + /// Agent logger. Match uses Info, mismatch uses Error (V1 parity). + /// Optional explicit path. null = use Process.GetCurrentProcess().MainModule.FileName. + /// Optional auto-heal hook. Params: (zipHash, allowDowngrade) → Task. + /// Value of AgentConfigResponse.LatestAgentSha256 — forwarded to the trigger. + /// Value of AgentConfigResponse.AllowAgentDowngrade — forwarded to the trigger. public static IntegrityCheckResult Check( string expectedSha256, AgentLogger logger, - string exePath = null) + string exePath = null, + Func runtimeSelfUpdateTrigger = null, + string zipHash = null, + bool allowDowngrade = false) { if (logger == null) throw new ArgumentNullException(nameof(logger)); if (string.IsNullOrWhiteSpace(expectedSha256)) { - logger.Debug("BinaryIntegrityVerifier: no expected hash provided — skipping check."); + logger.Debug("Post-config integrity check: no expected hash provided — skipping."); return IntegrityCheckResult.Skipped(); } - var path = exePath ?? Assembly.GetExecutingAssembly().Location; + var path = exePath ?? ResolveMainModulePath(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { - logger.Warning($"BinaryIntegrityVerifier: executing assembly path not resolvable ({path ?? "null"}) — skipping check."); + logger.Warning($"Post-config integrity check: cannot resolve EXE path ({path ?? "null"}) — skipping."); return IntegrityCheckResult.Skipped(); } @@ -56,22 +76,79 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } catch (Exception ex) { - logger.Warning($"BinaryIntegrityVerifier: failed to hash '{path}': {ex.Message}"); + logger.Warning($"Post-config integrity check: hashing failed — {ex.Message}. Skipping."); return IntegrityCheckResult.Skipped(); } if (string.Equals(actualSha, expectedSha256, StringComparison.OrdinalIgnoreCase)) { - logger.Debug($"BinaryIntegrityVerifier: SHA-256 match (sha={Truncate(actualSha)})."); + logger.Info($"Post-config integrity check: SHA-256 verified OK ({Truncate(actualSha)})."); return IntegrityCheckResult.Match(actualSha); } - logger.Warning( - $"BinaryIntegrityVerifier: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, backend advertises={Truncate(expectedSha256)}. " + - "Possible tamper, stale blob, or failed self-update."); + logger.Error( + $"Post-config integrity check: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, " + + $"backend advertises={Truncate(expectedSha256)} — possible tamper, stale blob, or failed self-update."); + + TriggerRuntimeSelfUpdate(logger, runtimeSelfUpdateTrigger, zipHash, allowDowngrade); + return IntegrityCheckResult.Mismatch(actualSha, expectedSha256); } + /// Exposed for tests — resets the single-shot trigger so each test starts clean. + internal static void ResetTriggerForTests() => Interlocked.Exchange(ref _runtimeSelfUpdateFired, 0); + + private static void TriggerRuntimeSelfUpdate( + AgentLogger logger, + Func runtimeSelfUpdateTrigger, + string zipHash, + bool allowDowngrade) + { + if (runtimeSelfUpdateTrigger == null) + { + logger.Warning("Runtime self-update trigger not wired — mismatch reported but no auto-heal possible."); + return; + } + + if (Interlocked.Exchange(ref _runtimeSelfUpdateFired, 1) == 1) + { + logger.Debug("Runtime self-update trigger already fired this session — ignoring."); + return; + } + + // Run the auto-heal on the thread pool — SelfUpdater.CheckAndApplyUpdateAsync + // blocks for the version/ZIP fetch and, on success, calls Environment.Exit before + // returning, so the awaiter never resolves. Do NOT block the caller on this. + Task.Run(async () => + { + try + { + logger.Info($"Runtime self-update trigger: invoking SelfUpdater (forceUpdate=true, allowDowngrade={allowDowngrade})."); + await runtimeSelfUpdateTrigger(zipHash, allowDowngrade).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Error("Runtime self-update trigger failed.", ex); + } + }); + } + + private static string ResolveMainModulePath() + { + try + { + // V1 parity — MonitoringService.VerifyAgentBinaryIntegrity uses + // Process.GetCurrentProcess().MainModule.FileName, NOT Assembly.Location. + // Under ngen / single-file publish scenarios MainModule resolves to the real + // on-disk image while Assembly.Location can be empty or point at a shadow copy. + return Process.GetCurrentProcess().MainModule?.FileName; + } + catch + { + return null; + } + } + internal static string ComputeSha256Hex(string filePath) { using (var stream = File.OpenRead(filePath)) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs index e9fb5325..a1e4b15c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs @@ -132,6 +132,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security lock (_lockObject) return File.Exists(_whiteGloveMarkerPath); } + /// + /// V1 parity (SessionPersistence.SaveWhiteGloveComplete) — persists the + /// whiteglove.complete marker so the next agent boot recognises this as a + /// Part-2 resume (). Without this marker the + /// emergency-break watchdog would keep counting the session's wall-clock age through + /// the Part-1/Part-2 power-off window and eventually trip during Part-2 setup. + /// + public void SaveWhiteGloveComplete(AgentLogger logger = null) + { + lock (_lockObject) + { + try + { + File.WriteAllText(_whiteGloveMarkerPath, DateTime.UtcNow.ToString("O")); + logger?.Info("SessionIdPersistence: whiteglove.complete marker saved — Part 2 detection enabled."); + } + catch (Exception ex) + { + logger?.Warning($"SessionIdPersistence: SaveWhiteGloveComplete failed: {ex.Message}"); + } + } + } + + /// + /// Clears the whiteglove.complete marker once Part-2 resumes so a subsequent + /// session does not re-enter the resume code path. + /// + public void ClearWhiteGloveComplete(AgentLogger logger = null) + { + lock (_lockObject) TryDelete(_whiteGloveMarkerPath, logger); + } + /// Deletes the persisted SessionId. Next starts a fresh session. public void Delete(AgentLogger logger = null) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index 9edff8da..253e13c0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,8 +10,10 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Termination { @@ -17,28 +21,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination /// Orchestrates peripheral termination work in response to /// . Plan §4.x M4.6.β. /// - /// Sequence (Legacy parity — single-writer, best-effort each step, never throws): + /// Sequence (V1 parity — single-writer, best-effort each step, never throws): /// /// + /// Run shutdown analyzers (optional) so their delta events land before diagnostics. /// Compose via . - /// Write final-status.json + launch SummaryDialog via . - /// Upload diagnostics via (respects - /// DiagnosticsUploadMode: Off / Always / OnFailure). - /// Write enrollment-complete.marker (so ghost-restart detection on next boot - /// exits cleanly even if failed). + /// Write final-status.json + launch if configured. + /// Emit enrollment_summary_shown event once the dialog has been handed to the user session. + /// 2s grace so late events can land before the next step. + /// Emit diagnostics_collecting → upload diagnostics → emit diagnostics_uploaded / diagnostics_upload_failed. + /// Write enrollment-complete.marker (ghost-restart guard on next boot). + /// Standalone-reboot path: if RebootOnComplete && !SelfDestructOnComplete emit + /// reboot_triggered, drain spool, call shutdown.exe /r /t <delay>. /// Run — UNLESS the stage is - /// (Part 1 exit, session resumes Part 2). - /// Signal the caller-owned shutdown so Program.cs' - /// main loop exits the shutdown.Wait() and calls orchestrator.Stop(). + /// (Part-1 exit, session resumes Part 2). + /// WhiteGlove Part-1 path: emit whiteglove_part1_complete, drain spool, and + /// write whiteglove.complete marker via + /// so Part-2 resume is detected on the next boot. + /// Signal the caller-owned shutdown . /// /// /// Each step logs + continues on failure — nothing in here is allowed to prevent the agent - /// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script - /// that waits for process exit); this handler does not block waiting for the script. + /// from shutting down. is fire-and-forget (spawns a PowerShell + /// cleanup script that waits for process exit); this handler does not block waiting for it. /// /// public sealed class EnrollmentTerminationHandler { + private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000); + private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000); // V1 parity: up to 20 × 500ms drains before shutdown.exe + private readonly AgentConfiguration _configuration; private readonly AgentLogger _logger; private readonly string _stateDirectory; @@ -50,6 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination private readonly Action _signalShutdown; private readonly string _dialogExePathOverride; private readonly AgentAnalyzerManager _analyzerManager; + private readonly Action _emitEvent; + private readonly SessionIdPersistence _sessionPersistence; + private readonly Action _triggerReboot; + private readonly TimeSpan _lateEventGracePeriod; + private readonly TimeSpan _spoolDrainPeriod; private int _handled; @@ -64,7 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination Func> uploadDiagnosticsAsync, Action signalShutdown, string dialogExePathOverride = null, - AgentAnalyzerManager analyzerManager = null) + AgentAnalyzerManager analyzerManager = null, + Action emitEvent = null, + SessionIdPersistence sessionPersistence = null, + Action triggerReboot = null, + TimeSpan? lateEventGracePeriod = null, + TimeSpan? spoolDrainPeriod = null) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -77,6 +99,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown)); _dialogExePathOverride = dialogExePathOverride; _analyzerManager = analyzerManager; + _emitEvent = emitEvent; + _sessionPersistence = sessionPersistence; + _triggerReboot = triggerReboot ?? DefaultTriggerReboot; + _lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace; + _spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain; } /// @@ -86,6 +113,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination { if (Interlocked.Exchange(ref _handled, 1) == 1) return; + var isWhiteGlovePart1 = args.StageName == SessionStage.WhiteGloveSealed.ToString(); + try { _logger.Info( @@ -107,8 +136,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination RunBuildAndLaunchDialog(state, args); } - RunUploadDiagnostics(args); + // WhiteGlove Part-1 exit: keep the session alive, but announce the handoff so + // the timeline clearly marks the transition. The `whiteglove.complete` marker + // lets the next agent boot classify itself as a Part-2 resume. + if (isWhiteGlovePart1) + { + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "whiteglove_part1_complete", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "WhiteGlove Part 1 complete — device will seal for end-user.", + ImmediateUpload = true, + }); + + DelayLateEventGrace(); + DrainSpool(); + + TrySaveWhiteGloveComplete(); + return; + } + + DelayLateEventGrace(); + + RunUploadDiagnosticsWithEvents(args); WriteEnrollmentCompleteMarker(args); + RunStandaloneRebootIfRequested(); RunSelfDestructIfAppropriate(args); } catch (Exception ex) @@ -166,6 +222,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination var packages = TryGetPackageStates(); var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc); SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride); + + if (_configuration.ShowEnrollmentSummary) + { + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "enrollment_summary_shown", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "Enrollment summary dialog shown to user.", + Data = new Dictionary + { + { "totalApps", status?.AppSummary?.TotalApps ?? 0 }, + { "errorCount", status?.AppSummary?.ErrorCount ?? 0 }, + { "outcome", status?.Outcome ?? string.Empty }, + { "timeoutSeconds", _configuration.EnrollmentSummaryTimeoutSeconds }, + }, + ImmediateUpload = true, + }); + } } catch (Exception ex) { @@ -173,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination } } - private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args) + private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args) { var mode = _configuration.DiagnosticsUploadMode ?? "Off"; if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) @@ -189,19 +267,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination return; } + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_collecting", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "Collecting diagnostics package.", + Data = new Dictionary + { + { "mode", mode }, + { "enrollmentSucceeded", enrollmentSucceeded }, + }, + ImmediateUpload = true, + }); + + DiagnosticsUploadResult result = null; try { var suffix = enrollmentSucceeded ? "success" : "failure"; - var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); - if (result != null && result.Success) - _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); - else - _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}."); + result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); } catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}"); } + + if (result != null && result.Success) + { + _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_uploaded", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Diagnostics package uploaded ({result.BlobName}).", + Data = new Dictionary + { + { "blobName", result.BlobName ?? string.Empty }, + { "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + else + { + var errorCode = result?.ErrorCode ?? "null-result"; + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}."); + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_upload_failed", + Severity = EventSeverity.Warning, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Diagnostics upload failed: {errorCode}.", + Data = new Dictionary + { + { "errorCode", errorCode }, + { "blobName", result?.BlobName ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } } private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args) @@ -228,6 +362,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination } } + /// + /// V1 parity — standalone-reboot flow. When the tenant config disables self-destruct + /// but enables RebootOnComplete, the agent's final act is shutdown.exe /r + /// with the configured delay, giving the user a visible countdown. + /// + private void RunStandaloneRebootIfRequested() + { + if (_configuration.SelfDestructOnComplete) return; + if (!_configuration.RebootOnComplete) return; + + var delay = _configuration.RebootDelaySeconds > 0 ? _configuration.RebootDelaySeconds : 10; + + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "reboot_triggered", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Standalone reboot triggered (delay={delay}s).", + Data = new Dictionary + { + { "rebootDelaySeconds", delay }, + { "selfDestructOnComplete", _configuration.SelfDestructOnComplete }, + }, + ImmediateUpload = true, + }); + + DrainSpool(); + + try + { + _triggerReboot(delay); + _logger.Info($"EnrollmentTerminationHandler: standalone reboot queued via shutdown.exe /r /t {delay}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: standalone reboot invocation failed: {ex.Message}"); + } + } + private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args) { if (!_configuration.SelfDestructOnComplete) @@ -253,5 +429,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex); } } + + private void TrySaveWhiteGloveComplete() + { + if (_sessionPersistence == null) + { + _logger.Warning("EnrollmentTerminationHandler: sessionPersistence not wired — whiteglove.complete marker NOT written (Part-2 detection will fail)."); + return; + } + + try { _sessionPersistence.SaveWhiteGloveComplete(_logger); } + catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: SaveWhiteGloveComplete threw: {ex.Message}"); } + } + + private void DelayLateEventGrace() + { + if (_lateEventGracePeriod <= TimeSpan.Zero) return; + try { Task.Delay(_lateEventGracePeriod).Wait(); } + catch { /* best-effort */ } + } + + private void DrainSpool() + { + // V1 parity — block briefly so pending events can land before the next destructive + // step (shutdown.exe, self-destruct). A precise HasPending/PendingCount signal is + // internal to the orchestrator transport, so the V2 equivalent is a bounded wait. + if (_spoolDrainPeriod <= TimeSpan.Zero) return; + try { Task.Delay(_spoolDrainPeriod).Wait(); } + catch { /* best-effort */ } + } + + private void EmitEventSafe(EnrollmentEvent evt) + { + if (_emitEvent == null) return; + try { _emitEvent(evt); } + catch (Exception ex) { _logger.Debug($"EnrollmentTerminationHandler: event emission '{evt?.EventType}' threw: {ex.Message}"); } + } + + private static void DefaultTriggerReboot(int delaySeconds) + { + var psi = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"), + Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + Process.Start(psi); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index ad4dea54..d61bbc23 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -206,12 +206,19 @@ namespace AutopilotMonitor.Agent.V2 // set by --install, no session.id yet) as a ghost restart and trigger // ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData // directory on every fresh deployment. + var sessionPersistence = new SessionIdPersistence(dataDirectory); if (args.Contains("--new-session")) { - new SessionIdPersistence(dataDirectory).Delete(logger); + sessionPersistence.Delete(logger); logger.Info("--new-session: cleared persisted SessionId."); } - agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger); + // Snapshot the WhiteGlove-resume state BEFORE GetOrCreate: on Part-2 resume we want + // to emit the whiteglove_resumed event AFTER orchestrator.Start. Reading the marker + // up front avoids racing any downstream clear. V1 parity — the Part-2 detection is + // marker-based, and the agent announces resume on the session timeline so that + // dashboards can correlate the session's two boots. + var isWhiteGloveResume = sessionPersistence.IsWhiteGloveResume(); + agentConfig.SessionId = sessionPersistence.GetOrCreate(logger); // Build a cleanup-service factory used by guards: instantiated lazily and with the // current agentConfig so command-line overrides (e.g. --no-cleanup) take effect. @@ -310,11 +317,14 @@ namespace AutopilotMonitor.Agent.V2 // 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); + logger: logger, + distressReporter: distressReporter); var remoteConfigService = new RemoteConfigService( backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker); @@ -323,8 +333,10 @@ namespace AutopilotMonitor.Agent.V2 // 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. CLI flags stay authoritative over remote. - RemoteConfigMerger.Merge(agentConfig, remoteConfig, args); + // 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); @@ -338,10 +350,32 @@ namespace AutopilotMonitor.Agent.V2 SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256; // Post-config binary-integrity check: verify the running EXE's SHA-256 against the - // value advertised by the backend. Mismatch does not terminate (SelfUpdater's own - // hash-based trigger covers the "wrong EXE" case at next startup) — we just emit an - // IntegrityCheckFailed error report so operators see the drift. - var integrityResult = BinaryIntegrityVerifier.Check(remoteConfig.LatestAgentExeSha256, logger); + // 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( @@ -461,7 +495,9 @@ namespace AutopilotMonitor.Agent.V2 cleanupServiceFactory: () => new CleanupService(agentConfig, logger), uploadDiagnosticsAsync: uploadDiagnosticsAsync, signalShutdown: () => shutdown.Set(), - analyzerManager: analyzerManager); + analyzerManager: analyzerManager, + emitEvent: evt => orchestrator.EventEmitter.Emit(evt), + sessionPersistence: sessionPersistence); // ServerActionDispatcher — handlers are live and the M4.6.ε response-parse // path below feeds it. RotateConfig refetches remote config; @@ -522,14 +558,85 @@ namespace AutopilotMonitor.Agent.V2 Console.CancelKeyPress += cancelHandler; AppDomain.CurrentDomain.ProcessExit += processExitHandler; + + // V1 parity — on the max-lifetime watchdog firing, emit an explicit + // `enrollment_failed` event with failureType=agent_timeout BEFORE the + // regular termination path runs. Dashboards + KQL queries key on the + // event type + data dictionary to distinguish a genuine enrollment failure + // from a timeout-triggered shutdown. + EventHandler maxLifetimeEmitter = (s, e) => + { + if (e.Reason != EnrollmentTerminationReason.MaxLifetimeExceeded) return; + try + { + var uptimeMin = (DateTime.UtcNow - agentStartTimeUtc).TotalMinutes; + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "enrollment_failed", + Severity = EventSeverity.Error, + Source = "EnrollmentOrchestrator", + Phase = EnrollmentPhase.Complete, + Message = $"Agent max lifetime expired ({uptimeMin:F0} min) — enrollment did not complete in time", + Data = new System.Collections.Generic.Dictionary + { + { "failureType", "agent_timeout" }, + { "failureSource", "max_lifetime_timer" }, + { "agentUptimeMinutes", Math.Round(uptimeMin, 1) }, + { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes }, + { "stageAtTimeout", e.StageName ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception emitEx) + { + logger.Warning($"enrollment_failed (max_lifetime) emission failed: {emitEx.Message}"); + } + }; + orchestrator.Terminated += maxLifetimeEmitter; orchestrator.Terminated += terminationHandler.Handle; // Auth-failure watchdog: when MaxAuthFailures / AuthFailureTimeoutMinutes // is exceeded the agent must shut down cleanly instead of hammering a // backend that has definitely said no. Event fires at most once. + // V1 parity — emit a structured `agent_shutdown` event with reason=auth_failure + // and the full telemetry payload before tripping the shutdown signal so the + // backend sees WHY the agent terminated in the session timeline. EventHandler authThresholdHandler = (s, e) => { logger.Error($"Auth-failure threshold exceeded ({e.Reason}) — initiating shutdown."); + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_shutdown", + Severity = EventSeverity.Error, + Source = "AuthFailureTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent shut down after {e.ConsecutiveFailures} consecutive auth failures", + Data = new System.Collections.Generic.Dictionary + { + { "reason", "auth_failure" }, + { "consecutiveFailures", e.ConsecutiveFailures }, + { "firstFailureTime", e.FirstFailureUtc.ToString("o") }, + { "maxFailures", agentConfig.MaxAuthFailures }, + { "timeoutMinutes", agentConfig.AuthFailureTimeoutMinutes }, + { "lastOperation", e.LastOperation ?? string.Empty }, + { "lastStatusCode", e.LastStatusCode }, + { "thresholdReason", e.Reason ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception emitEx) + { + logger.Warning($"agent_shutdown emission failed: {emitEx.Message}"); + } + shutdown.Set(); }; authFailureTracker.ThresholdExceeded += authThresholdHandler; @@ -554,6 +661,28 @@ namespace AutopilotMonitor.Agent.V2 // VersionCheckEventBuilder.TryBuild is a no-op when no markers are present. EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger); + // V1 parity (MonitoringService.AuditUnrestrictedModeChange) — whenever the + // remote tenant admin flips the UnrestrictedMode guardrail, emit an audit + // event so the change is visible in the session timeline and downstream + // alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive. + EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger); + + // V1 parity — agent_started / whiteglove_resumed / system_reboot_detected + // lifecycle events. These are purely informational (Phase=Unknown) but the + // session timeline and downstream alerting depend on them to classify the + // boot. + EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger); + if (isWhiteGloveResume) + { + EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger); + try { sessionPersistence.ClearWhiteGloveComplete(logger); } + catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); } + } + if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase)) + { + EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger); + } + // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / // IntegrityBypass). Runs on a background task inside AgentAnalyzerManager. try { analyzerManager.RunStartup(); } @@ -586,6 +715,7 @@ namespace AutopilotMonitor.Agent.V2 Console.CancelKeyPress -= cancelHandler; AppDomain.CurrentDomain.ProcessExit -= processExitHandler; orchestrator.Terminated -= terminationHandler.Handle; + orchestrator.Terminated -= maxLifetimeEmitter; authFailureTracker.ThresholdExceeded -= authThresholdHandler; try { orchestrator.Stop(); } @@ -671,6 +801,176 @@ namespace AutopilotMonitor.Agent.V2 }; } + /// + /// V1 parity — when flips the tenant-controlled + /// UnrestrictedMode guardrail, surface the transition as an auditable event on + /// the session timeline so operators can correlate subsequent gather-rule exec with the + /// elevated policy. The V1 code lives in + /// MonitoringService.AuditUnrestrictedModeChange. + /// + private static void EmitUnrestrictedModeAuditIfChanged( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + RemoteConfigMergeResult mergeResult, + AgentLogger logger) + { + if (mergeResult == null || !mergeResult.UnrestrictedModeChanged) return; + + try + { + var newValue = mergeResult.NewUnrestrictedMode; + logger.Warning( + $"UnrestrictedMode changed: {mergeResult.OldUnrestrictedMode} → {newValue}. Emitting audit event."); + + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_unrestricted_mode_changed", + Severity = newValue ? EventSeverity.Warning : EventSeverity.Info, + Source = "RemoteConfigMerger", + Phase = EnrollmentPhase.Unknown, + Message = newValue + ? "Agent unrestricted mode ENABLED — gather rules can now execute without AllowList checks" + : "Agent unrestricted mode disabled — gather rules revert to AllowList checks", + Data = new System.Collections.Generic.Dictionary + { + { "oldValue", mergeResult.OldUnrestrictedMode }, + { "newValue", newValue }, + { "changedAtUtc", DateTime.UtcNow.ToString("o") }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"EmitUnrestrictedModeAuditIfChanged: emission failed: {ex.Message}"); + } + } + + /// + /// V1 parity — fire-and-forget agent_started event emitted after + /// . Carries a snapshot of the boot classification + /// () and the tenant-influenced runtime knobs so dashboards + /// can classify crash-loops, backend-rejected sessions and forced self-destruct runs. + /// Phase stays — the event is NOT a phase declaration. + /// + private static void EmitAgentStartedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + PreviousExitSummary previousExit, + AgentLogger logger) + { + try + { + var data = new System.Collections.Generic.Dictionary + { + { "agentVersion", GetAgentVersion() }, + { "commandLineArgs", agentConfig.CommandLineArgs ?? string.Empty }, + { "isBootstrapSession", agentConfig.UseBootstrapTokenAuth }, + { "awaitEnrollment", agentConfig.AwaitEnrollment }, + { "selfDestructOnComplete", agentConfig.SelfDestructOnComplete }, + { "certAuth", !agentConfig.UseBootstrapTokenAuth }, + { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes }, + { "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" }, + { "previousExitType", previousExit?.ExitType ?? "unknown" }, + { "unrestrictedMode", agentConfig.UnrestrictedMode }, + }; + + if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType)) + data["previousCrashException"] = previousExit.CrashExceptionType; + + if (previousExit?.LastBootUtc.HasValue == true) + data["previousBootUtc"] = previousExit.LastBootUtc.Value.ToString("o"); + + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_started", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent v{GetAgentVersion()} started (previousExit={previousExit?.ExitType ?? "unknown"}).", + Data = data, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"agent_started emission failed: {ex.Message}"); + } + } + + /// + /// Emits whiteglove_resumed when the whiteglove.complete marker was present at + /// boot — signals that the agent has resumed Part 2 after the device reseal. + /// + private static void EmitWhiteGloveResumedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + AgentLogger logger) + { + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "whiteglove_resumed", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = "WhiteGlove Part 2 resume detected — continuing user-phase monitoring.", + Data = new System.Collections.Generic.Dictionary + { + { "resumedAtUtc", DateTime.UtcNow.ToString("o") }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"whiteglove_resumed emission failed: {ex.Message}"); + } + } + + /// + /// Emits system_reboot_detected when the previous agent exit was classified as + /// reboot_kill — the OS rebooted mid-session. Gives the backend a hook to split + /// the session into pre-reboot and post-reboot segments. + /// + private static void EmitSystemRebootDetectedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + PreviousExitSummary previousExit, + AgentLogger logger) + { + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "system_reboot_detected", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.", + Data = new System.Collections.Generic.Dictionary + { + { "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty }, + { "previousExitType", previousExit?.ExitType ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"system_reboot_detected emission failed: {ex.Message}"); + } + } + private static void EmitVersionCheckEventIfAny( EnrollmentOrchestrator orchestrator, AgentConfiguration agentConfig, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index c739b42c..f906e184 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -44 +96