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/Program/BuildAgentConfigurationReplayTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs new file mode 100644 index 00000000..d205ebf3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs @@ -0,0 +1,66 @@ +using System; +using AutopilotMonitor.Agent.V2; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Verifies that + /// propagates the dev-only --replay-log-dir + --replay-speed-factor CLI flags + /// onto so the IME log replay SimulationMode actually + /// activates downstream. V2 parity PR-A. + /// + public sealed class BuildAgentConfigurationReplayTests + { + private static AgentConfiguration Build(params string[] args) => + AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration( + args: args, + tenantId: "tenant-t", + sessionId: "session-s", + bootstrapConfig: null, + awaitConfig: null); + + [Fact] + public void Without_replay_flags_ReplayLogDir_stays_null_and_speed_factor_default() + { + var cfg = Build(); + + Assert.Null(cfg.ReplayLogDir); + Assert.Equal(50.0, cfg.ReplaySpeedFactor); + } + + [Fact] + public void Replay_log_dir_populates_configuration() + { + var cfg = Build("--replay-log-dir", @"C:\tmp\ime-logs"); + + Assert.Equal(@"C:\tmp\ime-logs", cfg.ReplayLogDir); + } + + [Fact] + public void Replay_speed_factor_is_parsed_as_invariant_double() + { + // Comma locale must NOT flip the meaning — parser uses InvariantCulture. + var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "125.5"); + + Assert.Equal(125.5, cfg.ReplaySpeedFactor); + } + + [Fact] + public void Replay_speed_factor_zero_is_rejected_and_default_kept() + { + var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "0"); + + Assert.Equal(50.0, cfg.ReplaySpeedFactor); + } + + [Fact] + public void Replay_speed_factor_malformed_is_rejected_and_default_kept() + { + var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "fast"); + + Assert.Equal(50.0, cfg.ReplaySpeedFactor); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs new file mode 100644 index 00000000..30443fd9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs @@ -0,0 +1,125 @@ +using System.IO; +using System.Reflection; +using AutopilotMonitor.Agent.V2; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Verifies that + /// emits the SessionStarted decision signal with the registration metadata payload the + /// reducer anchor needs. Plan §M4-V2-parity PR-C. + /// + public sealed class PostSessionStartedSignalTests + { + private static AgentLogger NewLogger(string path) + => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info); + + private static SessionRegistrationResult BuildSucceeded(ValidatorType validatedBy, string? adminAction = null) + { + var response = new RegisterSessionResponse + { + SessionId = "session-anon-1", + Success = true, + Message = "ok", + ValidatedBy = validatedBy, + AdminAction = adminAction, + }; + + // SessionRegistrationResult uses a private ctor + public static factories. Call + // Succeeded via reflection-free factory — it's public. + return SessionRegistrationResult.Succeeded(response); + } + + private static AgentConfiguration BuildConfig(bool bootstrap) + { + return new AgentConfiguration + { + ApiBaseUrl = "https://example.invalid", + SessionId = "session-anon-1", + TenantId = "tenant-anon-1", + SpoolDirectory = Path.GetTempPath(), + LogDirectory = Path.GetTempPath(), + LogLevel = AgentLogLevel.Info, + UseClientCertAuth = !bootstrap, + UseBootstrapTokenAuth = bootstrap, + BootstrapToken = bootstrap ? "dummy" : null, + }; + } + + [Fact] + public void Posts_session_started_kind_with_default_schema_version() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.AutopilotV1); + + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger); + + Assert.Single(sink.Posted); + var posted = sink.Posted[0]; + Assert.Equal(DecisionSignalKind.SessionStarted, posted.Kind); + Assert.Equal(1, posted.KindSchemaVersion); + Assert.Equal("Program.RunAgent", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind); + Assert.Equal("register_session_success", posted.Evidence.Identifier); + } + + [Fact] + public void Payload_includes_registration_metadata_keys() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.CorporateIdentifier); + + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: true), logger); + + var payload = sink.Posted[0].Payload; + Assert.NotNull(payload); + Assert.Equal("CorporateIdentifier", payload!["validatedBy"]); + Assert.Equal("true", payload["isBootstrapSession"]); + Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary)payload); + Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary)payload); + Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary)payload); + } + + [Fact] + public void Swallows_sink_exceptions() + { + // The helper is called on the hot startup path — a misbehaving sink must not + // crash the agent. + using var tmp = new TempDirectory(); + var sink = new ThrowingSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.Bootstrap); + + var ex = Record.Exception(() => + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger)); + + Assert.Null(ex); + } + + private sealed class ThrowingSink : AutopilotMonitor.Agent.V2.Core.Orchestration.ISignalIngressSink + { + public void Post( + DecisionSignalKind kind, + System.DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + System.Collections.Generic.IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1) + { + throw new System.InvalidOperationException("ingress unavailable"); + } + } + } +} 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/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs new file mode 100644 index 00000000..c67aa43f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +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.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Security +{ + /// + /// V1 parity guard for . The helper's retry + short- + /// circuit behaviour is the single replacement for V1 MonitoringService.RegisterSessionAsync. + /// If these tests break, production enrollment registration is at risk. + /// + public sealed class SessionRegistrationHelperTests + { + private static AgentLogger NewLogger(TempDirectory tmp) + => new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug); + + private static AgentConfiguration NewConfig() => new AgentConfiguration + { + ApiBaseUrl = "https://example.test", + SessionId = Guid.NewGuid().ToString(), + TenantId = Guid.NewGuid().ToString(), + }; + + [Fact] + public async Task Succeeds_on_first_response_success() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(attempts => new RegisterSessionResponse + { + Success = true, + SessionId = config.SessionId, + ValidatedBy = ValidatorType.AutopilotV1, + }); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + backoffDelay: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome); + Assert.Equal(1, apiClient.CallCount); + Assert.Equal(ValidatorType.AutopilotV1, result.ValidatedBy); + Assert.Null(result.AdminAction); + } + + [Fact] + public async Task Retries_until_success_on_success_false() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(attempt => + { + if (attempt < 3) return new RegisterSessionResponse { Success = false, Message = "backend 500" }; + return new RegisterSessionResponse { Success = true, SessionId = config.SessionId }; + }); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + backoffDelay: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome); + Assert.Equal(3, apiClient.CallCount); + } + + [Fact] + public async Task Auth_failure_short_circuits_without_retry() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(attempt => + throw new BackendAuthException("cert rejected", statusCode: 401)); + + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, authFailureTracker: tracker); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(401, result.HttpStatusCode); + Assert.Equal(1, apiClient.CallCount); + Assert.Equal(1, tracker.ConsecutiveFailures); + } + + [Fact] + public async Task Fails_after_five_exceptions_without_auth_error() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(attempt => + throw new InvalidOperationException($"transient attempt {attempt}")); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + backoffDelay: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome); + Assert.Equal(5, apiClient.CallCount); + Assert.Contains("transient attempt 5", result.ErrorMessage); + } + + [Fact] + public async Task Populates_registration_with_device_and_version_details() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + SessionRegistration captured = null!; + var apiClient = new FakeApiClient( + _ => new RegisterSessionResponse { Success = true, SessionId = config.SessionId }, + onRegister: reg => captured = reg); + + await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "9.9.9", logger: logger); + + Assert.NotNull(captured); + Assert.Equal(config.SessionId, captured.SessionId); + Assert.Equal(config.TenantId, captured.TenantId); + Assert.Equal("9.9.9", captured.AgentVersion); + Assert.Equal(Environment.MachineName, captured.DeviceName); + Assert.False(string.IsNullOrEmpty(captured.EnrollmentType)); + Assert.True(captured.IsUserDriven); + } + + // Subclasses the real BackendApiClient so we inherit its public API but intercept the + // single virtual entry point that matters. BackendApiClient's ctor is protected-less in + // V2 (`public BackendApiClient(string baseUrl, ...)` + `protected BackendApiClient()`); + // we use the protected ctor to avoid HttpClient construction side effects in unit tests. + private sealed class FakeApiClient : BackendApiClient + { + private readonly Func _respond; + private readonly Action _onRegister; + public int CallCount; + + public FakeApiClient(Func respond, Action onRegister = null) + { + _respond = respond; + _onRegister = onRegister; + } + + public override Task RegisterSessionAsync(SessionRegistration registration) + { + CallCount++; + _onRegister?.Invoke(registration); + try { return Task.FromResult(_respond(CallCount)); } + catch (Exception ex) { return Task.FromException(ex); } + } + } + } +} 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/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs index b971054a..377f8ccf 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs @@ -121,9 +121,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport } /// - /// Registers a new enrollment session + /// Registers a new enrollment session. + /// + /// virtual so test doubles (e.g. SessionRegistrationHelperTests.FakeApiClient) + /// can intercept the HTTP call without spinning up a real mTLS stack. Production path is + /// unchanged — the virtual dispatch cost is negligible vs. the network round-trip. + /// /// - public async Task RegisterSessionAsync(SessionRegistration registration) + public virtual async Task RegisterSessionAsync(SessionRegistration registration) { var request = new RegisterSessionRequest { Registration = registration }; var endpoint = _useBootstrapTokenAuth diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index fbd80b40..aa8c319c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -36,11 +36,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// /// Optional peripheral hosts (driven by toggles): /// - /// PerformanceHost — CPU/memory/disk samples. - /// AgentSelfMetricsHost — process CPU, memory and HTTP traffic counters. - /// Wires into the instance created by the Program.cs - /// (the V2 - /// has no metrics — accepted M4.5.b tech-debt). + /// PeriodicCollectorLifecycleHost — owns PerformanceCollector (CPU / + /// memory / disk samples) and AgentSelfMetricsCollector (process CPU, memory and + /// HTTP traffic counters) under a single idle-timeout window (V1 parity with + /// PeriodicCollectorManager). Wires AgentSelfMetricsCollector into the + /// instance created by the Program.cs + /// . + /// NetworkChangeHost — owns NetworkChangeDetector for WiFi / SSID / + /// default-route / MDM-reachability transitions (V1 parity with + /// CollectorCoordinator.StartOptionalCollectors:375-382). /// /// /// @@ -122,6 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration hosts.Add(new AadJoinHost(logger, ingress, clock)); + // Dev / test — if --replay-log-dir is set, the tracker reads from the replay folder + // with SimulationMode ON + the configured SpeedFactor instead of tailing the live + // IME log folder. Production agents leave ReplayLogDir empty. + var simulationMode = !string.IsNullOrEmpty(_agentConfig.ReplayLogDir); + var imeLogFolder = simulationMode + ? _agentConfig.ReplayLogDir + : _agentConfig.ImeLogPathOverride; + _imeLogHost = new ImeLogHost( sessionId: sessionId, tenantId: tenantId, @@ -129,11 +141,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logger: logger, ingress: ingress, clock: clock, - imeLogPathOverride: _agentConfig.ImeLogPathOverride, + imeLogPathOverride: imeLogFolder, imeMatchLogPath: _agentConfig.ImeMatchLogPath, imePatterns: _remoteConfig.ImeLogPatterns, stateDirectory: _stateDirectory, - whiteGloveSealingPatternIds: whiteGloveSealingPatternIds); + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds, + simulationMode: simulationMode, + simulationSpeedFactor: _agentConfig.ReplaySpeedFactor); hosts.Add(_imeLogHost); if (collectors.StallProbeEnabled) @@ -154,17 +168,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // ----- Peripheral hosts (event-only; driven by remote-config toggles) -------------- - if (collectors.EnablePerformanceCollector) + // V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under + // a single host that stops both after CollectorIdleTimeoutMinutes of no real enrollment + // activity and restarts them on the next real event. Without the idle timeout the two + // collectors run for the agent's entire lifetime (up to AgentMaxLifetime / 6 h), + // which drains battery + wastes bandwidth on dormant sessions. + if (collectors.EnablePerformanceCollector || (collectors.EnableAgentSelfMetrics && _networkMetrics != null)) { - hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger, - collectors.PerformanceIntervalSeconds)); + hosts.Add(new PeriodicCollectorLifecycleHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + performanceEnabled: collectors.EnablePerformanceCollector, + performanceIntervalSeconds: collectors.PerformanceIntervalSeconds, + selfMetricsEnabled: collectors.EnableAgentSelfMetrics && _networkMetrics != null, + selfMetricsIntervalSeconds: collectors.AgentSelfMetricsIntervalSeconds, + idleTimeoutMinutes: collectors.CollectorIdleTimeoutMinutes, + networkMetrics: _networkMetrics, + agentVersion: _agentVersion)); } - if (collectors.EnableAgentSelfMetrics && _networkMetrics != null) - { - hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger, - _networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds)); - } + // V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the + // NetworkChangeDetector. It captures WiFi SSID / default route / IPv4 / reachability + // changes and emits `network_change` events. Events are already debounced internally + // (5s); no separate remote-config toggle in V1 either. + hosts.Add(new NetworkChangeHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + apiBaseUrl: _agentConfig.ApiBaseUrl)); // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the // IME log tracker reports an app entering Downloading/Installing (see AppStateChanged @@ -192,7 +226,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 +244,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 +253,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() @@ -368,6 +411,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker; + /// + /// Exposes the tracker's simulation flag so the Dev / Test CLI flag + /// --replay-log-dir is testable without poking through reflection. + /// + internal bool IsSimulationMode => _tracker.SimulationMode; + public ImeLogHost( string sessionId, string tenantId, @@ -379,7 +428,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration string? imeMatchLogPath, List? imePatterns, string stateDirectory, - IReadOnlyCollection? whiteGloveSealingPatternIds) + IReadOnlyCollection? whiteGloveSealingPatternIds, + bool simulationMode = false, + double simulationSpeedFactor = 50) { _logger = logger; var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!; @@ -395,6 +446,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration matchLogPath: expandedMatchLogPath, stateDirectory: stateDirectory); + if (simulationMode) + { + _tracker.SimulationMode = true; + _tracker.SpeedFactor = simulationSpeedFactor; + logger.Info($"ImeLogHost: SimulationMode ENABLED (speedFactor={simulationSpeedFactor}, path={logFolder})"); + } + _adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds); _processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger); @@ -507,33 +565,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } } - private sealed class PerformanceHost : ICollectorHost - { - public string Name => "PerformanceCollector"; - - private readonly PerformanceCollector _collector; - private int _disposed; - - public PerformanceHost( - string sessionId, - string tenantId, - Action onEnrollmentEvent, - AgentLogger logger, - int intervalSeconds) - { - _collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds); - } - - public void Start() => _collector.Start(); - public void Stop() => _collector.Stop(); - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) == 1) return; - try { _collector.Dispose(); } catch { } - } - } - private sealed class DeliveryOptimizationHost : ICollectorHost { public string Name => "DeliveryOptimizationCollector"; @@ -570,13 +601,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory)); } + private Action? _chainedHandler; + public void Start() { // Chain ourselves into the tracker's OnAppStateChanged so the DO collector wakes // up on the first Downloading/Installing transition (Legacy parity). We preserve // any existing handler — the IME adapter's own listener must keep running. _prevStateChanged = _imeHost.Tracker.OnAppStateChanged; - _imeHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) => + _chainedHandler = (pkg, oldState, newState) => { try { _prevStateChanged?.Invoke(pkg, oldState, newState); } catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); } @@ -588,6 +621,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); } } }; + _imeHost.Tracker.OnAppStateChanged = _chainedHandler; _collector.Start(); _logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing)."); @@ -596,9 +630,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public void Stop() { // Restore the previous handler only if we're still the current one — otherwise - // someone else replaced us and owns the slot now. - if (object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _imeHost.Tracker.OnAppStateChanged)) + // someone else replaced us and owns the slot now. The V1 bug here compared the + // handler slot against itself (always true) which silently overwrote any newer + // handler that had since taken over; the captured `_chainedHandler` reference + // lets us make the check actually meaningful. + if (_chainedHandler != null + && object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _chainedHandler)) + { _imeHost.Tracker.OnAppStateChanged = _prevStateChanged; + } _collector.Stop(); } @@ -609,33 +649,273 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } } - private sealed class AgentSelfMetricsHost : ICollectorHost + /// + /// V1 parity (PeriodicCollectorManager). Owns the + /// and and enforces the + /// CollectorIdleTimeoutMinutes window: both stop after N minutes of no real event + /// activity and restart on the next real (non-periodic) event. Without this the two + /// collectors ran forever in V2 and filled the telemetry spool on dormant sessions. + /// + /// Activity is observed by wrapping the onEnrollmentEvent sink: every event that + /// flows through this host bumps _lastRealEventTimeUtc unless it matches a + /// well-known periodic event type (performance_snapshot, agent_metrics_snapshot, + /// stall_probe_*, session_stalled). Events emitted by the managed + /// collectors themselves therefore never reset their own idle window. + /// + /// + private sealed class PeriodicCollectorLifecycleHost : ICollectorHost { - public string Name => "AgentSelfMetricsCollector"; + public string Name => "PeriodicCollectorLifecycleHost"; - private readonly AgentSelfMetricsCollector _collector; + private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60); + private static readonly HashSet PeriodicEventTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "performance_snapshot", + "agent_metrics_snapshot", + "performance_collector_stopped", + "agent_metrics_collector_stopped", + "stall_probe_check", + "stall_probe_result", + "session_stalled", + }; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + private readonly bool _perfEnabled; + private readonly int _perfIntervalSeconds; + private readonly bool _selfMetricsEnabled; + private readonly int _selfMetricsIntervalSeconds; + private readonly int _idleTimeoutMinutes; + private readonly NetworkMetrics? _networkMetrics; + private readonly string _agentVersion; + + private readonly object _sync = new object(); + private PerformanceCollector? _performanceCollector; + private AgentSelfMetricsCollector? _selfMetricsCollector; + private Timer? _idleTimer; + private DateTime _lastRealEventTimeUtc; + private bool _idleStopped; private int _disposed; - public AgentSelfMetricsHost( + public PeriodicCollectorLifecycleHost( string sessionId, string tenantId, Action onEnrollmentEvent, AgentLogger logger, - NetworkMetrics networkMetrics, - string agentVersion, - int intervalSeconds) + bool performanceEnabled, + int performanceIntervalSeconds, + bool selfMetricsEnabled, + int selfMetricsIntervalSeconds, + int idleTimeoutMinutes, + NetworkMetrics? networkMetrics, + string agentVersion) { - _collector = new AgentSelfMetricsCollector( - sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds); + _sessionId = sessionId; + _tenantId = tenantId; + _emitEvent = WrapEventSink(onEnrollmentEvent); + _logger = logger; + _perfEnabled = performanceEnabled; + _perfIntervalSeconds = performanceIntervalSeconds; + _selfMetricsEnabled = selfMetricsEnabled; + _selfMetricsIntervalSeconds = selfMetricsIntervalSeconds; + _idleTimeoutMinutes = idleTimeoutMinutes; + _networkMetrics = networkMetrics; + _agentVersion = agentVersion; + _lastRealEventTimeUtc = DateTime.UtcNow; } - public void Start() => _collector.Start(); - public void Stop() => _collector.Stop(); + public void Start() + { + lock (_sync) + { + StartCollectorsInternal(); + if (_idleTimeoutMinutes > 0) + { + _idleTimer = new Timer(_ => IdleCheckTick(), state: null, + dueTime: IdleTickInterval, period: IdleTickInterval); + _logger.Info($"PeriodicCollectorLifecycleHost: started (idle timeout={_idleTimeoutMinutes}min)."); + } + else + { + _logger.Info("PeriodicCollectorLifecycleHost: started (idle timeout disabled)."); + } + } + } + + public void Stop() + { + lock (_sync) + { + try { _idleTimer?.Dispose(); } catch { } + _idleTimer = null; + StopCollectorsInternal(); + } + } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 1) return; - try { _collector.Dispose(); } catch { } + Stop(); + } + + private void StartCollectorsInternal() + { + if (_perfEnabled && _performanceCollector == null) + { + _performanceCollector = new PerformanceCollector( + _sessionId, _tenantId, _emitEvent, _logger, _perfIntervalSeconds); + _performanceCollector.Start(); + } + if (_selfMetricsEnabled && _selfMetricsCollector == null && _networkMetrics != null) + { + _selfMetricsCollector = new AgentSelfMetricsCollector( + _sessionId, _tenantId, _emitEvent, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds); + _selfMetricsCollector.Start(); + } + _idleStopped = false; + } + + private void StopCollectorsInternal() + { + try { _performanceCollector?.Stop(); } catch { } + try { _performanceCollector?.Dispose(); } catch { } + _performanceCollector = null; + + try { _selfMetricsCollector?.Stop(); } catch { } + try { _selfMetricsCollector?.Dispose(); } catch { } + _selfMetricsCollector = null; + } + + private Action WrapEventSink(Action inner) + { + return evt => + { + if (evt == null) + { + inner(null!); + return; + } + if (!string.IsNullOrEmpty(evt.EventType) + && !PeriodicEventTypes.Contains(evt.EventType)) + { + OnRealEvent(); + } + inner(evt); + }; + } + + private void OnRealEvent() + { + lock (_sync) + { + _lastRealEventTimeUtc = DateTime.UtcNow; + if (_idleStopped) + { + _logger.Info("PeriodicCollectorLifecycleHost: real event detected — restarting periodic collectors."); + StartCollectorsInternal(); + } + } + } + + private void IdleCheckTick() + { + try + { + lock (_sync) + { + if (_idleStopped) return; + if (_idleTimeoutMinutes <= 0) return; + + var idleMinutes = (DateTime.UtcNow - _lastRealEventTimeUtc).TotalMinutes; + if (idleMinutes < _idleTimeoutMinutes) return; + + _logger.Info($"PeriodicCollectorLifecycleHost: idle for {idleMinutes:F0}min (limit={_idleTimeoutMinutes}) — stopping collectors."); + + var hadPerformance = _performanceCollector != null; + var hadSelfMetrics = _selfMetricsCollector != null; + StopCollectorsInternal(); + _idleStopped = true; + + if (hadPerformance) + EmitIdleStopped("performance_collector_stopped", idleMinutes); + if (hadSelfMetrics) + EmitIdleStopped("agent_metrics_collector_stopped", idleMinutes); + } + } + catch (Exception ex) + { + _logger.Warning($"PeriodicCollectorLifecycleHost: idle-check tick threw: {ex.Message}"); + } + } + + private void EmitIdleStopped(string eventType, double idleMinutes) + { + try + { + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = EventSeverity.Info, + Source = "PeriodicCollectorLifecycleHost", + Phase = EnrollmentPhase.Unknown, + Message = $"{eventType} after {idleMinutes:F0}min idle (no real enrollment activity).", + Data = new Dictionary + { + { "reason", "idle_timeout" }, + { "idleTimeoutMinutes", _idleTimeoutMinutes }, + { "idleMinutes", Math.Round(idleMinutes, 1) }, + }, + }); + } + catch (Exception ex) { _logger.Debug($"PeriodicCollectorLifecycleHost: emit '{eventType}' threw: {ex.Message}"); } + } + } + + /// + /// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — owns the + /// lifecycle of . Without this host the detector class + /// existed in V2 but was never instantiated, so WiFi / SSID / connectivity transitions + /// were invisible to the backend. + /// + private sealed class NetworkChangeHost : ICollectorHost + { + public string Name => "NetworkChangeDetector"; + + private readonly NetworkChangeDetector _detector; + private readonly AgentLogger _logger; + private int _disposed; + + public NetworkChangeHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + string? apiBaseUrl) + { + _logger = logger; + _detector = new NetworkChangeDetector(sessionId, tenantId, onEnrollmentEvent, logger, apiBaseUrl); + } + + public void Start() + { + try { _detector.Start(); } + catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Start failed: {ex.Message}"); } + } + + public void Stop() + { + try { _detector.Stop(); } + catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Stop failed: {ex.Message}"); } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _detector.Dispose(); } catch { } } } } 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/EnrollmentRegistryDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs new file mode 100644 index 00000000..39eff4f7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs @@ -0,0 +1,75 @@ +using System; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Read-only registry probes for enrollment metadata the agent has to pin down at session + /// registration time. V1 parity — these mirror EnrollmentTracker.DetectHybridJoinStatic + /// and EnrollmentTracker.DetectEnrollmentTypeStatic from the legacy agent. + /// + /// Kept stateless and exception-swallowing so the registration pipeline can call them + /// before any other component is up. A missing / inaccessible registry key degrades to + /// the default value (v1 / non-hybrid) rather than throwing — mirrors V1 behaviour. + /// + /// + public static class EnrollmentRegistryDetector + { + private const string AutopilotPolicyCacheKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"; + private const string AutopilotSettingsKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; + + /// + /// Reads CloudAssignedDomainJoinMethod from the Autopilot policy cache. Returns + /// true when the profile was deployed with Hybrid Azure AD Join + /// (CloudAssignedDomainJoinMethod == 1), false otherwise or on any error. + /// + public static bool DetectHybridJoin() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(AutopilotPolicyCacheKey)) + { + if (key == null) return false; + var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString(); + return domainJoinMethod == "1"; + } + } + catch + { + return false; + } + } + + /// + /// Classifies the Autopilot flow based on the AutopilotSettings registry: + /// + /// CloudAssignedDeviceRegistration == 2 → "v2" (Windows Device Preparation). + /// CloudAssignedEspEnabled == 0 → "v2" (no ESP, WDP indicator). + /// Anything else → "v1" (Classic Autopilot / ESP). + /// + /// Defaults to "v1" on any error so a flaky registry does not misclassify a + /// classic session as WDP. + /// + public static string DetectEnrollmentType() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey)) + { + if (key == null) return "v1"; + + var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString(); + if (deviceReg == "2") return "v2"; + + var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString(); + if (espEnabled == "0") return "v2"; + } + } + catch + { + // Fall through to v1 default. + } + return "v1"; + } + } +} 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/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs new file mode 100644 index 00000000..fa19c424 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs @@ -0,0 +1,194 @@ +using System; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// V1-parity wrapper around . Plan §3.9 / + /// PR #51. The backend's /api/agent/register-session is the authoritative point where + /// a session row is created in the Sessions table; without it, every subsequent + /// IncrementSessionEventCountAsync / UpdateSessionStatusAsync silently no-ops + /// (the server logs a warning and returns) so events still land but session status, + /// phase, admin-overrides and validator reconciliation break. + /// + /// Retry contract (V1 MonitoringService.RegisterSessionAsync): + /// + /// + /// 5 attempts total; between attempts 2^attempt seconds (2s, 4s, 8s, 16s). + /// On response.Success == true → stop immediately. + /// On (401/403) → report to the + /// (which fires the first-failure distress + may trip + /// the shutdown threshold) and return + /// without retrying — the backend has definitively rejected the device cert. + /// Any other exception on the last attempt → + /// with AgentErrorType.RegisterSessionFailed so operators see the final cause. + /// + /// + /// The caller (Program.RunAgent) must treat a non- + /// outcome as fatal — V1 parity: "=== SESSION REGISTRATION FAILED — collectors will NOT start + /// to prevent orphaned events ===". The agent exits cleanly instead of spinning up + /// the orchestrator and flooding the Events table for an unregistered session. + /// + /// + public static class SessionRegistrationHelper + { + private const int MaxAttempts = 5; + + /// + /// Executes the 5-retry register-session handshake against the backend. Returns the + /// response + classified outcome. Never throws. + /// + /// + /// Optional delay-provider used by tests to avoid real-time waits. Production callers + /// pass null → V1 parity Task.Delay(2^attempt * 1000) between attempts. + /// + public static async Task RegisterWithRetryAsync( + BackendApiClient apiClient, + AgentConfiguration agentConfig, + string agentVersion, + AgentLogger logger, + AuthFailureTracker authFailureTracker = null, + EmergencyReporter emergencyReporter = null, + Func backoffDelay = null) + { + if (apiClient == null) throw new ArgumentNullException(nameof(apiClient)); + if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + backoffDelay ??= DefaultBackoffDelay; + var registration = BuildRegistration(agentConfig, agentVersion); + string lastError = null; + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + try + { + logger.Info($"Registering session with backend (attempt {attempt}/{MaxAttempts})"); + var response = await apiClient.RegisterSessionAsync(registration).ConfigureAwait(false); + + if (response != null && response.Success) + { + logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})"); + return SessionRegistrationResult.Succeeded(response); + } + + lastError = response?.Message ?? "(null response)"; + logger.Warning($"Session registration failed: {lastError}"); + } + catch (BackendAuthException ex) + { + logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}"); + // Feed the central auth-failure tracker so the first-failure distress dispatches + // and the shutdown threshold advances. V1 parity: no retry on auth-failure — + // backend has definitively rejected the cert/token. + authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session"); + return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message); + } + catch (Exception ex) + { + lastError = ex.Message; + logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex); + + if (attempt == MaxAttempts) + { + try + { + if (emergencyReporter != null) + _ = emergencyReporter.TrySendAsync( + AgentErrorType.RegisterSessionFailed, + ex.Message); + } + catch { /* emergency channel is best-effort */ } + } + } + + if (attempt < MaxAttempts) + { + var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16 + logger.Info($"Retrying session registration in {delaySeconds}s"); + await backoffDelay(attempt).ConfigureAwait(false); + } + } + + return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded"); + } + + /// V1 parity exponential backoff: 2^attempt seconds. + private static Task DefaultBackoffDelay(int attempt) + => Task.Delay(((int)Math.Pow(2, attempt)) * 1000); + + private static SessionRegistration BuildRegistration(AgentConfiguration agentConfig, string agentVersion) + { + return new SessionRegistration + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + SerialNumber = DeviceInfoProvider.GetSerialNumber() ?? string.Empty, + Manufacturer = DeviceInfoProvider.GetManufacturer() ?? string.Empty, + Model = DeviceInfoProvider.GetModel() ?? string.Empty, + DeviceName = Environment.MachineName, + OsName = DeviceInfoProvider.GetOsName() ?? string.Empty, + OsBuild = DeviceInfoProvider.GetOsBuild() ?? string.Empty, + OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion() ?? string.Empty, + OsEdition = DeviceInfoProvider.GetOsEdition() ?? string.Empty, + OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name ?? string.Empty, + StartedAt = DateTime.UtcNow, + AgentVersion = agentVersion ?? string.Empty, + EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(), + IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(), + IsUserDriven = true, + }; + } + } + + /// Classification of . + public enum SessionRegistrationOutcome + { + /// Backend responded with Success=true. The session row is live. + Succeeded = 0, + + /// 401/403 from the backend — cert/token rejected. No retry was attempted. + AuthFailed = 1, + + /// All 5 attempts failed with non-auth errors (network, 5xx, malformed response). + Failed = 2, + } + + /// Result of . + public sealed class SessionRegistrationResult + { + public SessionRegistrationOutcome Outcome { get; } + public RegisterSessionResponse Response { get; } + public int HttpStatusCode { get; } + public string ErrorMessage { get; } + + public string AdminAction => Response?.AdminAction; + public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown; + + private SessionRegistrationResult( + SessionRegistrationOutcome outcome, + RegisterSessionResponse response, + int httpStatusCode, + string errorMessage) + { + Outcome = outcome; + Response = response; + HttpStatusCode = httpStatusCode; + ErrorMessage = errorMessage; + } + + public static SessionRegistrationResult Succeeded(RegisterSessionResponse response) + => new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null); + + public static SessionRegistrationResult AuthFailed(int statusCode, string message) + => new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message); + + public static SessionRegistrationResult Failed(string message) + => new SessionRegistrationResult(SessionRegistrationOutcome.Failed, null, 0, message); + } +} 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..db01c975 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -16,6 +16,8 @@ using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; @@ -206,12 +208,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 +319,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 +335,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 +352,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( @@ -395,6 +431,34 @@ namespace AutopilotMonitor.Agent.V2 return 5; } + // V1 parity (MonitoringService.Start, MonitoringService.RegisterSessionAsync) — + // POST /api/agent/register-session with 5-retry exponential backoff (2s/4s/8s/16s) + // BEFORE the orchestrator starts. Without this call the backend's Sessions table + // never gets a row for this session, so IncrementSessionEventCountAsync / + // UpdateSessionStatusAsync silently no-op — events still land in the Events table + // but session status, phase, admin-overrides and validator reconcile all break. + // On failure we follow V1's rule: collectors MUST NOT start to prevent orphaned events. + var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient: backendApiClient, + agentConfig: agentConfig, + agentVersion: GetAgentVersion(), + logger: logger, + authFailureTracker: authFailureTracker, + emergencyReporter: emergencyReporter).GetAwaiter().GetResult(); + + if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded) + { + logger.Error( + $"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " + + "collectors will NOT start to prevent orphaned events. ==="); + if (consoleMode) + Console.Error.WriteLine($"FATAL: session registration failed ({registrationResult.Outcome}). Agent exiting."); + try { mtlsHttpClient?.Dispose(); } catch { } + try { backendApiClient?.Dispose(); } catch { } + // Exit code differs so the diag skill can distinguish Auth vs Network in Scheduled-Task history. + return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7; + } + var classifiers = new IClassifier[] { new WhiteGloveSealingClassifier(), @@ -461,7 +525,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 +588,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 +691,59 @@ 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 lifecycle event. Purely informational + // (Phase=Unknown); the session timeline and downstream alerting rely on + // it to classify the boot. + EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger); + + // WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts + // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge — that + // reducer effect emits whiteglove_resumed on the timeline. We only need + // to clear the persisted marker here. + if (isWhiteGloveResume) + { + try { sessionPersistence.ClearWhiteGloveComplete(logger); } + catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); } + } + + // Reboot mid-session: post SystemRebootObserved so the reducer records + // the fact and emits the system_reboot_detected timeline entry. + if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase)) + { + PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger); + } + + // V2 parity — post SessionStarted so the reducer establishes the session anchor + // (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on: + // - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts + // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge. + // - Admin preemption: the AdminPreemptionDetected signal below drives the + // session straight to a terminal stage; SessionStarted first would be noise. + if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction)) + { + PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger); + } + + // V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") — + // if the register-session response carried an AdminAction the operator + // has already marked the session terminal via the portal. Post + // AdminPreemptionDetected so the reducer transitions Stage to Completed / + // Failed and emits the enrollment_complete/_failed timeline event as a + // side effect. The orchestrator's DecisionStepProcessor picks up the + // terminal stage and raises Terminated, which runs the termination + // pipeline (cleanup + summary + self-destruct) through the subscribed + // handler — no direct synthesis needed. + if (!string.IsNullOrEmpty(registrationResult.AdminAction)) + { + PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger); + } + // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / // IntegrityBypass). Runs on a background task inside AgentAnalyzerManager. try { analyzerManager.RunStartup(); } @@ -586,6 +776,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 +862,224 @@ 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}"); + } + } + + /// + /// V2 parity — post when the + /// register-session response carried an AdminAction. The reducer handler at + /// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the + /// terminal state and emits the enrollment_complete/_failed telemetry event; the + /// orchestrator's DecisionStepProcessor then raises the Terminated event, which the + /// subscribed EnrollmentTerminationHandler picks up — no direct synthesis needed. + /// + private static void PostAdminPreemptionSignal( + ISignalIngressSink ingressSink, + SessionRegistrationResult registrationResult, + AgentLogger logger) + { + try + { + var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed" + logger.Warning( + $"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ==="); + + ingressSink.Post( + kind: DecisionSignalKind.AdminPreemptionDetected, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "register_session_response", + evidence: new Evidence( + kind: EvidenceKind.Synthetic, + identifier: $"admin_preemption:{adminOutcome}", + summary: $"Operator marked session as {adminOutcome} via portal before agent startup."), + payload: new System.Collections.Generic.Dictionary(StringComparer.Ordinal) + { + ["adminOutcome"] = adminOutcome, + }); + } + catch (Exception ex) + { + logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}"); + } + } + + /// + /// V2 parity — post a signal with the + /// tenant-registered session metadata (EnrollmentType / IsHybridJoin / ValidatedBy) so the + /// reducer's session-anchor handler runs. Without this the DecisionState.Stage stays at + /// the initial value and subsequent raw signals (ESP / Hello) see an uninitialised session. + /// + internal static void PostSessionStartedSignal( + ISignalIngressSink ingressSink, + SessionRegistrationResult registrationResult, + AgentConfiguration agentConfig, + AgentLogger logger) + { + try + { + var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal) + { + ["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(), + ["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false", + ["validatedBy"] = registrationResult.ValidatedBy.ToString(), + ["agentVersion"] = GetAgentVersion(), + ["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false", + }; + + var evidence = new Evidence( + kind: EvidenceKind.Synthetic, + identifier: "register_session_success", + summary: "Session registration handshake succeeded; posting SessionStarted anchor for reducer."); + + ingressSink.Post( + kind: DecisionSignalKind.SessionStarted, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "Program.RunAgent", + evidence: evidence, + payload: payload); + + logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]})."); + } + catch (Exception ex) + { + logger.Warning($"SessionStarted post 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}"); + } + } + + /// + /// V2 parity — post when the prior + /// agent process was terminated by an OS reboot. The reducer handler at + /// DecisionEngine.Edge.cs#HandleSystemRebootObservedV1 records the reboot fact on the + /// state (used by the WhiteGlove reboot-observed scoring weight, plan §2.4) and emits + /// the system_reboot_detected telemetry event as a side effect. + /// + private static void PostSystemRebootObservedSignal( + ISignalIngressSink ingressSink, + PreviousExitSummary previousExit, + AgentLogger logger) + { + try + { + var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal) + { + ["previousExitType"] = previousExit?.ExitType ?? string.Empty, + ["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty, + }; + + ingressSink.Post( + kind: DecisionSignalKind.SystemRebootObserved, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "Program.DetectPreviousExit", + evidence: new Evidence( + kind: EvidenceKind.Synthetic, + identifier: "previous_exit_reboot_kill", + summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."), + payload: payload); + } + catch (Exception ex) + { + logger.Warning($"SystemRebootObserved post failed: {ex.Message}"); + } + } + private static void EmitVersionCheckEventIfAny( EnrollmentOrchestrator orchestrator, AgentConfiguration agentConfig, @@ -704,7 +1113,7 @@ namespace AutopilotMonitor.Agent.V2 // ---------------------------------------------------------------- Configuration - private static AgentConfiguration BuildAgentConfiguration( + internal static AgentConfiguration BuildAgentConfiguration( string[] args, string tenantId, string sessionId, @@ -715,6 +1124,20 @@ namespace AutopilotMonitor.Agent.V2 var imeLogPathOverride = GetArgValue(args, "--ime-log-path"); var imeMatchLogPath = GetArgValue(args, "--ime-match-log"); + // Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode + // + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal + // timestamps + ingress ordinals are regenerated as the replay runs. + var replayLogDir = GetArgValue(args, "--replay-log-dir"); + var replaySpeedFactorRaw = GetArgValue(args, "--replay-speed-factor"); + var replaySpeedFactor = 50.0; + if (!string.IsNullOrEmpty(replaySpeedFactorRaw) + && double.TryParse(replaySpeedFactorRaw, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed) + && parsedSpeed > 0) + { + replaySpeedFactor = parsedSpeed; + } + var bootstrapToken = GetArgValue(args, "--bootstrap-token") ?? bootstrapConfig?.BootstrapToken; @@ -760,6 +1183,8 @@ namespace AutopilotMonitor.Agent.V2 KeepLogFile = keepLogFile, SelfDestructOnComplete = !noCleanup, CommandLineArgs = FormatArgsForLog(args), + ReplayLogDir = replayLogDir, + ReplaySpeedFactor = replaySpeedFactor, }; } @@ -860,6 +1285,10 @@ namespace AutopilotMonitor.Agent.V2 Console.Out.WriteLine(" --api-url Override backend API base URL (alias: --backend-api)"); Console.Out.WriteLine(" --ime-log-path Override IME logs directory"); Console.Out.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Dev / Test:"); + Console.Out.WriteLine(" --replay-log-dir Replay IME logs from this directory (simulation mode)"); + Console.Out.WriteLine(" --replay-speed-factor Time-compression factor for log replay (default: 50)"); } private static void PrintVersion() diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index c739b42c..e34885bb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -44 +107 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 7e179677..69559761 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1071 +1075 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs new file mode 100644 index 00000000..79619e94 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan V2-parity PR-B — the reducer is the single state owner, and telemetry events + /// for the three lifecycle signals (SystemRebootObserved, SessionRecovered→WG bridge, + /// AdminPreemptionDetected) appear as + /// effects rather than being synthesised by the V2 host. + /// + public sealed class ReducerTimelineEffectTests + { + private static DecisionSignal BuildSignal( + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null, + string sourceOrigin = "test", + int ordinal = 1) + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: sourceOrigin, + evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"), + payload: payload); + } + + // ---------------------------------------------------------------- SystemRebootObserved + + [Fact] + public void SystemRebootObservedV1_emits_system_reboot_detected_timeline_entry() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var payload = new Dictionary + { + ["previousExitType"] = "reboot_kill", + ["lastBootUtc"] = "2026-04-22T08:00:00.0000000Z", + }; + var signal = BuildSignal( + DecisionSignalKind.SystemRebootObserved, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + payload); + + var step = engine.Reduce(state, signal); + + Assert.NotNull(step.NewState.SystemRebootUtc); + var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry); + Assert.NotNull(timelineEffect.Parameters); + Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]); + Assert.Equal("reboot_kill", timelineEffect.Parameters["previousExitType"]); + Assert.Equal("2026-04-22T08:00:00.0000000Z", timelineEffect.Parameters["lastBootUtc"]); + } + + [Fact] + public void SystemRebootObservedV1_without_payload_still_emits_timeline_entry() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = BuildSignal( + DecisionSignalKind.SystemRebootObserved, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc)); + + var step = engine.Reduce(state, signal); + + var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry); + Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]); + Assert.False(timelineEffect.Parameters.ContainsKey("previousExitType")); + } + + // ------------------------------------------------------ WhiteGlovePart1To2 bridge + + [Fact] + public void WhiteGlovePart1To2Bridge_emits_whiteglove_resumed_timeline_entry() + { + var engine = new DecisionEngine(); + var sealedState = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.WhiteGloveSealed) + .Build(); + + var recoverSignal = BuildSignal( + DecisionSignalKind.SessionRecovered, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "EnrollmentOrchestrator"); + + var step = engine.Reduce(sealedState, recoverSignal); + + Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, step.NewState.Stage); + + var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry); + Assert.Equal("whiteglove_resumed", timelineEffect.Parameters!["eventType"]); + Assert.Equal("2026-04-22T10:00:00.0000000Z", timelineEffect.Parameters["resumedAtUtc"]); + Assert.Equal("EnrollmentOrchestrator", timelineEffect.Parameters["sourceOrigin"]); + + // ScheduleDeadline remains. + Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline); + } + + // ---------------------------------------------------- AdminPreemptionDetected + + [Fact] + public void AdminPreemptionDetectedV1_succeeded_transitions_to_completed_and_emits_complete_event() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = BuildSignal( + DecisionSignalKind.AdminPreemptionDetected, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + new Dictionary { ["adminOutcome"] = "Succeeded" }, + sourceOrigin: "register_session_response"); + + var step = engine.Reduce(state, signal); + + Assert.Equal(SessionStage.Completed, step.NewState.Stage); + Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome); + + var effect = Assert.Single(step.Effects); + Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind); + Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]); + Assert.Equal("Succeeded", effect.Parameters["adminAction"]); + Assert.Equal("register_session_response", effect.Parameters["source"]); + } + + [Fact] + public void AdminPreemptionDetectedV1_failed_transitions_to_failed_and_emits_failed_event() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = BuildSignal( + DecisionSignalKind.AdminPreemptionDetected, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + new Dictionary { ["adminOutcome"] = "Failed" }); + + var step = engine.Reduce(state, signal); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome); + + var effect = Assert.Single(step.Effects); + Assert.Equal("enrollment_failed", effect.Parameters!["eventType"]); + Assert.Equal("Failed", effect.Parameters["adminAction"]); + } + + [Fact] + public void AdminPreemptionDetectedV1_missing_outcome_defaults_to_failed() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = BuildSignal( + DecisionSignalKind.AdminPreemptionDetected, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + payload: null); + + var step = engine.Reduce(state, signal); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome); + } + + [Fact] + public void AdminPreemptionDetectedV1_clears_active_deadlines() + { + var engine = new DecisionEngine(); + var stateWithDeadline = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: DeadlineNames.ClassifierTick, + dueAtUtc: new DateTime(2026, 4, 22, 11, 0, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick, + })) + .Build(); + + var signal = BuildSignal( + DecisionSignalKind.AdminPreemptionDetected, + new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc), + new Dictionary { ["adminOutcome"] = "Succeeded" }); + + var step = engine.Reduce(stateWithDeadline, signal); + + Assert.Empty(step.NewState.Deadlines); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs index c414e942..73183aeb 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs @@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine /// /// Handle . Records the fact so - /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight. + /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits + /// the system_reboot_detected timeline entry so the session timeline shows the + /// split between pre-reboot and post-reboot collection. /// private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal) { @@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.SystemRebootObserved)); - return new DecisionStep(newState, transition, Array.Empty()); + var timelineParams = new Dictionary + { + ["eventType"] = "system_reboot_detected", + ["reason"] = "prior agent process was terminated by the reboot", + }; + if (signal.Payload != null) + { + if (signal.Payload.TryGetValue("previousExitType", out var previousExitType)) + timelineParams["previousExitType"] = previousExitType ?? string.Empty; + if (signal.Payload.TryGetValue("lastBootUtc", out var lastBootUtc)) + timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty; + } + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: timelineParams), + }; + + return new DecisionStep(newState, transition, effects); } } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index d2a55719..55050224 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine return new DecisionStep(newState, transition, Array.Empty()); } + /// + /// Handle . Plan §2.7 admin- + /// action audit; V2 parity PR-B3. + /// + /// Emitted by Program.RunAgent when the register-session response carries an + /// AdminAction value (operator marked the session terminal via the portal before + /// the agent even started). The signal payload carries + /// adminOutcome=Succeeded|Failed. + /// + /// + /// Stage transitions to (Succeeded) or + /// (anything else); + /// captures the non-enrollment nature of the transition so dashboards + KQL can tell + /// an admin-override apart from a genuine enrollment_complete/_failed. + /// + /// + private DecisionStep HandleAdminPreemptionDetectedV1(DecisionState state, DecisionSignal signal) + { + var adminOutcome = signal.Payload != null && signal.Payload.TryGetValue("adminOutcome", out var v) + ? v + : "Failed"; // defensive default: preemption without outcome is treated as failure. + + var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase); + var toStage = succeeded ? SessionStage.Completed : SessionStage.Failed; + var eventType = succeeded ? "enrollment_complete" : "enrollment_failed"; + + var nextStep = state.StepIndex + 1; + var newState = state.ToBuilder() + .WithStage(toStage) + .WithOutcome(SessionOutcome.AdminPreempted) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines() + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: toStage, + nextStepIndex: nextStep, + trigger: $"AdminPreemption:{adminOutcome}"); + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = eventType, + ["adminAction"] = adminOutcome, + ["source"] = signal.SourceOrigin ?? "register_session_response", + ["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).", + }), + }; + + return new DecisionStep(newState, transition, effects); + } + // ============================================================== shared helpers // Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers // as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs index 585b8043..2fc8b068 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs @@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine var effects = new[] { new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety), + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = "whiteglove_resumed", + ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"), + ["sourceOrigin"] = signal.SourceOrigin ?? string.Empty, + }), }; return new DecisionStep(newState, transition, effects); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 72c62d73..7928461f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine return (signal.Kind, signal.KindSchemaVersion) switch { // ----- Lifecycle (DecisionEngine.Shared.cs) ----- - (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal), - (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal), - (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal), - (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal), + (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal), + (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal), + (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal), + (DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal), + (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal), // ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) ----- (DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal), diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index a704411f..b9f28796 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals SessionStarted, SessionAborted, SessionRecovered, + + // --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) --- + AdminPreemptionDetected, } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs index 11694d36..ae7a5994 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs @@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State WhiteGlovePart1Sealed, WhiteGlovePart2Complete, Aborted, + + // V2 parity PR-B3 / plan §2.7 admin-action audit: + // Set when the register-session response carries an AdminAction + // (operator marked the session terminal via the portal before the + // agent even started). Stage transitions to Completed or Failed + // depending on the adminOutcome payload. + AdminPreempted, } } diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json new file mode 100644 index 00000000..9bc45f23 --- /dev/null +++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json @@ -0,0 +1,3 @@ +# AdminPreemptionDetected v1 — adminOutcome=Failed → Stage.Failed, +# Outcome=AdminPreempted, effect emits enrollment_failed timeline entry. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Failed","Summary":"Operator marked session as Failed via portal before agent startup."},"Payload":{"adminOutcome":"Failed"}} diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json new file mode 100644 index 00000000..e324a705 --- /dev/null +++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json @@ -0,0 +1,4 @@ +# AdminPreemptionDetected v1 — adminOutcome=Succeeded → Stage.Completed, +# Outcome=AdminPreempted, effect emits enrollment_complete timeline entry with +# adminAction=Succeeded + source=register_session_response. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Succeeded","Summary":"Operator marked session as Succeeded via portal before agent startup."},"Payload":{"adminOutcome":"Succeeded"}} diff --git a/tests/fixtures/signal-kinds/system-reboot-observed-v1.json b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json new file mode 100644 index 00000000..d07824df --- /dev/null +++ b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json @@ -0,0 +1,5 @@ +# SystemRebootObserved v1 — V2 parity PR-B2. +# Reducer handler HandleSystemRebootObservedV1 records SystemRebootUtc fact and emits +# system_reboot_detected timeline entry. Payload carries previousExitType + lastBootUtc +# so the backend can split the session into pre-/post-reboot segments. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"Program.DetectPreviousExit","Evidence":{"Kind":"Synthetic","Identifier":"previous_exit_reboot_kill","Summary":"Prior agent process terminated by OS reboot."},"Payload":{"previousExitType":"reboot_kill","lastBootUtc":"2026-04-22T08:00:00.0000000Z"}}