From fc778b4154ee9a059e7f43ce8aee5dea3f4eeaf0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 12:10:05 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(agent-v2):=20V1-parity=20remediation?= =?UTF-8?q?=20=E2=80=94=20lifecycle=20events,=20auth/binary=20watchdogs,?= =?UTF-8?q?=20remote=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the main V1/V2 divergences identified by the parity audit (tasks/plan_v2_parity.md). PR #47 — RemoteConfigMerger V1-parity - Tenant remote wins unconditionally for every 1:1-mapped knob (V1 parity with MonitoringService.ApplyRuntimeSettingsFromRemoteConfig). CLI flags only seed the initial AgentConfiguration. - Map EnableImeMatchLog → ImeMatchLogPath (Constants.ImeMatchLogPath default), with a safeguard for --ime-match-log dev overrides. - UploadIntervalSeconds, NtpServer, DiagnosticsUploadMode, DiagnosticsUploadEnabled, MaxAuthFailures, AuthFailureTimeoutMinutes all flow through. - Merge returns RemoteConfigMergeResult so the caller can audit UnrestrictedMode and LogLevel transitions. Program.cs emits `agent_unrestricted_mode_changed` after orchestrator.Start when the guardrail flips (V1 AuditUnrestrictedModeChange). PR #48 — BinaryIntegrityVerifier runtime self-update - Use Process.GetCurrentProcess().MainModule.FileName (V1 parity; Assembly.Location is unreliable under ngen / shadow copy). - Match → Info log; mismatch → Error log with V1-format message. - New `runtimeSelfUpdateTrigger` delegate fired with an Interlocked single-shot guard. Program.cs wires it to SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate=true, triggerReason="runtime_hash_mismatch") so the agent auto-heals on SHA drift. PR #49 — AuthFailureTracker V1-parity - Tracker is now the single dispatch point for auth-failure distress. First 401/403 → AuthCertificateRejected / DeviceNotRegistered distress (V1 HandleAuthFailure). Duplicate ConfigFetchDenied send removed from RemoteConfigService. - Threshold log uses the V1 === AGENT SHUTDOWN === banner. - Program.cs emits `agent_shutdown` (reason=auth_failure) with the full V1 data schema before tripping the shutdown signal. PR #50 — Lifecycle events + SaveWhiteGloveComplete + standalone reboot - SessionIdPersistence gains SaveWhiteGloveComplete / ClearWhiteGloveComplete so WhiteGlove Part-2 resume detection finally works (IsWhiteGloveResume was unreachable before because no path wrote the marker). - EnrollmentTerminationHandler emits the V1 lifecycle events: `enrollment_summary_shown`, `diagnostics_collecting`, `diagnostics_uploaded` / `diagnostics_upload_failed`, `whiteglove_part1_complete`, `reboot_triggered`. - Standalone-reboot flow: when SelfDestructOnComplete=false and RebootOnComplete=true the handler drains the spool briefly and invokes shutdown.exe /r /t . - Program.cs emits `agent_started` (with 11+ data fields), `whiteglove_resumed`, and `system_reboot_detected` (previousExitType=reboot_kill) after orchestrator.Start. PR #52 — Collector parity - EnrollmentOrchestrator's max-lifetime watchdog now emits a V1-format `enrollment_failed` event (failureType=agent_timeout, failureSource=max_lifetime_timer, Phase=Complete) in addition to raising Terminated(MaxLifetimeExceeded). - GatherRuleExecutorHost propagates AgentConfiguration.UnrestrictedMode into the runtime executor so gather rules can bypass AllowList checks when the tenant admin elevates the guardrail (V1 CollectorCoordinator.StartGatherRuleExecutor). Tests - 476/476 V2 tests pass (was 475 — one timing-sensitive mismatch test bumped to 10s). - 507/507 V1 Agent tests + 708/708 Backend tests unaffected. - New tests cover: Remote-wins-always, UnrestrictedMode audit, EnableImeMatchLog, runtime self-update trigger single-shot, distress type per status code, diagnostics event emission, standalone reboot, WhiteGlove marker write. Deferred - PR #51 (register-session 5-retry) is out of scope — V2 does not call /api/agent/register-session in the main flow today. Porting that is a feature addition, not a V1 parity fix. - PR #53 (emergency-break, RunStartupAnalyzers) already landed in main via earlier PRs #44–#46; verified against plan §3.11. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Configuration/RemoteConfigMergerTests.cs | 292 +++++++++------- .../Security/AuthFailureTrackerTests.cs | 75 ++++ .../Security/BinaryIntegrityVerifierTests.cs | 85 +++++ .../EnrollmentTerminationHandlerTests.cs | 121 ++++++- .../Configuration/RemoteConfigMerger.cs | 167 ++++++--- .../Configuration/RemoteConfigService.cs | 17 +- .../Orchestration/DefaultComponentFactory.cs | 15 +- .../Security/AuthFailureTracker.cs | 54 ++- .../Security/BinaryIntegrityVerifier.cs | 107 +++++- .../Security/SessionIdPersistence.cs | 32 ++ .../EnrollmentTerminationHandler.cs | 262 ++++++++++++-- .../AutopilotMonitor.Agent.V2/Program.cs | 320 +++++++++++++++++- .../buildcounter.txt | 2 +- 13 files changed, 1304 insertions(+), 245 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs index 56eb6b15..4f9bb69c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Xunit; @@ -9,8 +10,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration { /// /// Tests for . This merger is the single source of - /// Legacy-parity: every field delivered by the backend that has a live V2 consumer must - /// land on after FetchConfigAsync. + /// V1-parity: every field delivered by the backend that has a live V2 consumer must land + /// on after FetchConfigAsync. + /// + /// V1 parity: remote values OVERWRITE the runtime configuration unconditionally. CLI + /// flags only seed the initial and yield to tenant + /// policy once Merge runs. This is asserted by a regression test below. + /// /// public sealed class RemoteConfigMergerTests { @@ -46,6 +52,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration LogLevel = "Debug", SendTraceEvents = false, UnrestrictedMode = true, + MaxAuthFailures = 7, + AuthFailureTimeoutMinutes = 15, + EnableImeMatchLog = true, Collectors = new CollectorConfiguration { AgentMaxLifetimeMinutes = 120, @@ -56,12 +65,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration // ============================================================= Happy path [Fact] - public void Merge_applies_all_tenant_fields_when_no_cli_overrides() + public void Merge_applies_all_tenant_fields() { var agent = NewAgentConfig(); var remote = FullRemote(); - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + var result = RemoteConfigMerger.Merge(agent, remote); Assert.False(agent.SelfDestructOnComplete); Assert.True(agent.KeepLogFile); @@ -85,40 +94,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Equal(250, agent.MaxBatchSize); Assert.Equal(120, agent.AgentMaxLifetimeMinutes); Assert.Equal(45, agent.HelloWaitTimeoutSeconds); + Assert.Equal(7, agent.MaxAuthFailures); + Assert.Equal(15, agent.AuthFailureTimeoutMinutes); + + Assert.NotNull(result); + Assert.True(result.UnrestrictedModeChanged); + Assert.False(result.OldUnrestrictedMode); + Assert.True(result.NewUnrestrictedMode); + Assert.True(result.LogLevelChanged); + Assert.Equal(AgentLogLevel.Info, result.OldLogLevel); + Assert.Equal(AgentLogLevel.Debug, result.NewLogLevel); } - // ============================================================= CLI override wins + // ============================================================= Remote-wins-always (V1 parity) [Fact] - public void Merge_preserves_no_cleanup_cli_flag_over_remote_true() + public void Merge_remote_wins_over_prior_agent_self_destruct() { + // BuildAgentConfiguration may have set SelfDestructOnComplete from the --no-cleanup CLI + // flag. Once remote config arrives it takes over — V1 behaviour (operator cannot + // override tenant policy from the command line). var agent = NewAgentConfig(); - agent.SelfDestructOnComplete = false; // as set by BuildAgentConfiguration when CLI had --no-cleanup + agent.SelfDestructOnComplete = false; var remote = FullRemote(); - remote.SelfDestructOnComplete = true; // tenant enabled cleanup, dev disabled locally + remote.SelfDestructOnComplete = true; - RemoteConfigMerger.Merge(agent, remote, new[] { "--no-cleanup" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.False(agent.SelfDestructOnComplete); + Assert.True(agent.SelfDestructOnComplete); } [Fact] - public void Merge_preserves_keep_logfile_cli_flag_over_remote_false() - { - var agent = NewAgentConfig(); - agent.KeepLogFile = true; - - var remote = FullRemote(); - remote.KeepLogFile = false; - - RemoteConfigMerger.Merge(agent, remote, new[] { "--keep-logfile" }); - - Assert.True(agent.KeepLogFile); - } - - [Fact] - public void Merge_preserves_reboot_cli_flag_over_remote_false() + public void Merge_remote_wins_over_prior_agent_reboot_on_complete() { var agent = NewAgentConfig(); agent.RebootOnComplete = true; @@ -126,27 +134,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.RebootOnComplete = false; - RemoteConfigMerger.Merge(agent, remote, new[] { "--reboot-on-complete" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.True(agent.RebootOnComplete); + Assert.False(agent.RebootOnComplete); } [Fact] - public void Merge_preserves_disable_geolocation_cli_flag_over_remote_true() - { - var agent = NewAgentConfig(); - agent.EnableGeoLocation = false; - - var remote = FullRemote(); - remote.EnableGeoLocation = true; - - RemoteConfigMerger.Merge(agent, remote, new[] { "--disable-geolocation" }); - - Assert.False(agent.EnableGeoLocation); - } - - [Fact] - public void Merge_preserves_log_level_cli_arg_over_remote_string() + public void Merge_remote_wins_over_prior_agent_log_level() { var agent = NewAgentConfig(); agent.LogLevel = AgentLogLevel.Verbose; @@ -154,9 +148,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "Debug"; - RemoteConfigMerger.Merge(agent, remote, new[] { "--log-level", "Verbose" }); + RemoteConfigMerger.Merge(agent, remote); - Assert.Equal(AgentLogLevel.Verbose, agent.LogLevel); + Assert.Equal(AgentLogLevel.Debug, agent.LogLevel); } [Fact] @@ -166,7 +160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "trace"; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(AgentLogLevel.Trace, agent.LogLevel); } @@ -180,11 +174,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.LogLevel = "NotAValidLevel"; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(AgentLogLevel.Info, agent.LogLevel); } + [Fact] + public void Merge_remote_null_ntp_server_clears_prior_value() + { + // V1 parity: remote null overwrites local without a IsNullOrWhiteSpace gate. + var agent = NewAgentConfig(); + agent.NtpServer = "local.ntp"; + + var remote = FullRemote(); + remote.NtpServer = null!; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Null(agent.NtpServer); + } + + [Fact] + public void Merge_remote_off_overrides_prior_diagnostics_upload_mode() + { + // Tenant disables diagnostics upload — must take effect even when the agent was + // previously configured (e.g. by a prior config fetch cached on disk) to something + // else. V1 parity. + var agent = NewAgentConfig(); + agent.DiagnosticsUploadMode = "OnFailure"; + agent.DiagnosticsUploadEnabled = true; + + var remote = FullRemote(); + remote.DiagnosticsUploadMode = "Off"; + remote.DiagnosticsUploadEnabled = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal("Off", agent.DiagnosticsUploadMode); + Assert.False(agent.DiagnosticsUploadEnabled); + } + + [Fact] + public void Merge_unrestricted_mode_change_is_reported_via_result() + { + var agent = NewAgentConfig(); + agent.UnrestrictedMode = false; + + var remote = FullRemote(); + remote.UnrestrictedMode = true; + + var result = RemoteConfigMerger.Merge(agent, remote); + + Assert.True(result.UnrestrictedModeChanged); + Assert.False(result.OldUnrestrictedMode); + Assert.True(result.NewUnrestrictedMode); + Assert.True(agent.UnrestrictedMode); + } + + [Fact] + public void Merge_unrestricted_mode_stable_is_not_reported_as_change() + { + var agent = NewAgentConfig(); + agent.UnrestrictedMode = true; + + var remote = FullRemote(); + remote.UnrestrictedMode = true; + + var result = RemoteConfigMerger.Merge(agent, remote); + + Assert.False(result.UnrestrictedModeChanged); + } + + [Fact] + public void Merge_maps_enable_ime_match_log_true_to_default_path() + { + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = null; + + var remote = FullRemote(); + remote.EnableImeMatchLog = true; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.False(string.IsNullOrEmpty(agent.ImeMatchLogPath)); + Assert.Equal( + Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath), + agent.ImeMatchLogPath); + } + + [Fact] + public void Merge_maps_enable_ime_match_log_false_clears_path() + { + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + + var remote = FullRemote(); + remote.EnableImeMatchLog = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Null(agent.ImeMatchLogPath); + } + + [Fact] + public void Merge_preserves_custom_ime_match_log_path_when_remote_disables() + { + // A dev-time --ime-match-log override seeds a non-default path on the + // AgentConfiguration. When the tenant remote flips EnableImeMatchLog off we keep + // the custom path rather than clobbering a deliberate dev override. + var agent = NewAgentConfig(); + agent.ImeMatchLogPath = @"C:\Temp\custom-ime.log"; + + var remote = FullRemote(); + remote.EnableImeMatchLog = false; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath); + } + // ============================================================= Null / partial tolerance [Fact] @@ -194,22 +302,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration agent.NtpServer = "local.ntp"; agent.SelfDestructOnComplete = true; - RemoteConfigMerger.Merge(agent, null!, Array.Empty()); + var result = RemoteConfigMerger.Merge(agent, null!); Assert.Equal("local.ntp", agent.NtpServer); Assert.True(agent.SelfDestructOnComplete); - } - - [Fact] - public void Merge_accepts_null_cli_args() - { - var agent = NewAgentConfig(); - var remote = FullRemote(); - - RemoteConfigMerger.Merge(agent, remote, null!); - - Assert.False(agent.SelfDestructOnComplete); - Assert.Equal("pool.ntp.org", agent.NtpServer); + Assert.NotNull(result); + Assert.False(result.UnrestrictedModeChanged); } [Fact] @@ -222,40 +320,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.Collectors = null!; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(360, agent.AgentMaxLifetimeMinutes); Assert.Equal(30, agent.HelloWaitTimeoutSeconds); } - [Fact] - public void Merge_keeps_default_ntp_server_when_remote_is_empty() - { - var agent = NewAgentConfig(); - agent.NtpServer = "time.windows.com"; - - var remote = FullRemote(); - remote.NtpServer = ""; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal("time.windows.com", agent.NtpServer); - } - - [Fact] - public void Merge_keeps_default_diagnostics_mode_when_remote_is_null() - { - var agent = NewAgentConfig(); - agent.DiagnosticsUploadMode = "Off"; - - var remote = FullRemote(); - remote.DiagnosticsUploadMode = null!; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal("Off", agent.DiagnosticsUploadMode); - } - [Fact] public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null() { @@ -268,7 +338,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.DiagnosticsLogPaths = null!; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.NotNull(agent.DiagnosticsLogPaths); Assert.Empty(agent.DiagnosticsLogPaths); @@ -276,20 +346,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration // ============================================================= Int clamping - [Fact] - public void Merge_skips_non_positive_upload_interval() - { - var agent = NewAgentConfig(); - agent.UploadIntervalSeconds = 30; - - var remote = FullRemote(); - remote.UploadIntervalSeconds = 0; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.Equal(30, agent.UploadIntervalSeconds); - } - [Fact] public void Merge_skips_non_positive_max_batch_size() { @@ -299,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration var remote = FullRemote(); remote.MaxBatchSize = -1; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(100, agent.MaxBatchSize); } @@ -307,6 +363,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration [Fact] public void Merge_skips_non_positive_hello_wait_timeout() { + // V1 parity: MonitoringService.ApplyRuntimeSettingsFromRemoteConfig only applies the + // remote value when > 0 so an accidentally misconfigured tenant does not drop the + // Hello-wait to zero. var agent = NewAgentConfig(); agent.HelloWaitTimeoutSeconds = 30; @@ -317,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration HelloWaitTimeoutSeconds = 0, }; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(30, agent.HelloWaitTimeoutSeconds); } @@ -335,7 +394,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration HelloWaitTimeoutSeconds = 30, }; - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); + RemoteConfigMerger.Merge(agent, remote); Assert.Equal(0, agent.AgentMaxLifetimeMinutes); } @@ -346,26 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration public void Merge_throws_on_null_agent_config() { Assert.Throws(() => - RemoteConfigMerger.Merge(null!, FullRemote(), Array.Empty())); - } - - // ============================================================= The user's scenario - - [Fact] - public void Merge_flips_self_destruct_off_when_tenant_admin_disables_cleanup_and_agent_runs_without_cli_args() - { - // Regression guard for the VM smoke-test bug: tenant config has SelfDestructOnComplete=false - // but agent was built with CLI-default true, and without the merger the remote value never - // reached CleanupService / EnrollmentTerminationHandler. - var agent = NewAgentConfig(); - agent.SelfDestructOnComplete = true; // BuildAgentConfiguration CLI-default when no --no-cleanup - - var remote = FullRemote(); - remote.SelfDestructOnComplete = false; - - RemoteConfigMerger.Merge(agent, remote, Array.Empty()); - - Assert.False(agent.SelfDestructOnComplete); + RemoteConfigMerger.Merge(null!, FullRemote())); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs index f168e5a2..8b87e372 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs @@ -1,8 +1,12 @@ using System; +using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Security @@ -226,6 +230,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security new AuthFailureTracker(1, 0, new FakeClock(DateTime.UtcNow), null!)); } + // ============================================================= Distress dispatch (V1 parity) + + [Fact] + public void RecordFailure_dispatches_distress_only_on_first_failure_for_401() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(401, "agent/config"); + tracker.RecordFailure(401, "agent/config"); + tracker.RecordFailure(401, "agent/config"); + + Assert.Single(fakeDistress.Calls); + Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType); + Assert.Equal(401, fakeDistress.Calls[0].HttpStatusCode); + Assert.Contains("agent/config", fakeDistress.Calls[0].Message); + } + + [Fact] + public void RecordFailure_dispatches_device_not_registered_for_403() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(403, "agent/telemetry"); + + Assert.Single(fakeDistress.Calls); + Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType); + Assert.Equal(403, fakeDistress.Calls[0].HttpStatusCode); + } + + [Fact] + public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger()); + + int events = 0; + tracker.ThresholdExceeded += (_, _) => events++; + + tracker.RecordFailure(401, "op"); + tracker.RecordFailure(401, "op"); + + Assert.Equal(1, events); + } + + private sealed class FakeDistressReporter : DistressReporter + { + public sealed class Call + { + public DistressErrorType ErrorType { get; set; } + public string Message { get; set; } = default!; + public int? HttpStatusCode { get; set; } + } + + public List Calls { get; } = new List(); + + public FakeDistressReporter() + : base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!) + { + } + + public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null) + { + Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode }); + return Task.CompletedTask; + } + } + private sealed class FakeClock : AutopilotMonitor.DecisionCore.Engine.IClock { public DateTime Now { get; set; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs index 2bd59ded..24ce8897 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs @@ -1,4 +1,7 @@ +using System; using System.IO; +using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; @@ -96,5 +99,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash); } + + [Fact] + public void Check_match_does_not_invoke_runtime_self_update_trigger() + { + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x01, 0x02 }); + var actual = BinaryIntegrityVerifier.ComputeSha256Hex(exe); + + var triggerCount = 0; + Func trigger = (_, __) => + { + Interlocked.Increment(ref triggerCount); + return Task.CompletedTask; + }; + + var result = BinaryIntegrityVerifier.Check(actual, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip", allowDowngrade: false); + + Assert.Equal(IntegrityCheckOutcome.Match, result.Outcome); + Assert.Equal(0, Volatile.Read(ref triggerCount)); + } + + [Fact] + public void Check_mismatch_fires_runtime_self_update_trigger_exactly_once() + { + // V1 parity + single-shot guard: repeat calls at runtime (e.g. config-rotation hash + // drift) must not fan out into multiple force-update attempts. + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x01 }); + var wrongHash = new string('f', 64); + + var triggerCount = 0; + string capturedZipHash = null; + bool capturedDowngrade = false; + var triggerDone = new ManualResetEventSlim(false); + Func trigger = (zip, down) => + { + Interlocked.Increment(ref triggerCount); + capturedZipHash = zip; + capturedDowngrade = down; + triggerDone.Set(); + return Task.CompletedTask; + }; + + var r1 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true); + var r2 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true); + + Assert.Equal(IntegrityCheckOutcome.Mismatch, r1.Outcome); + Assert.Equal(IntegrityCheckOutcome.Mismatch, r2.Outcome); + + Assert.True(triggerDone.Wait(TimeSpan.FromSeconds(10)), "trigger did not run within 10s"); + Assert.Equal(1, Volatile.Read(ref triggerCount)); + Assert.Equal("zip-hash", capturedZipHash); + Assert.True(capturedDowngrade); + } + + [Fact] + public void Check_mismatch_without_trigger_only_logs() + { + BinaryIntegrityVerifier.ResetTriggerForTests(); + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + + var exe = Path.Combine(tmp.Path, "fake.exe"); + File.WriteAllBytes(exe, new byte[] { 0x07 }); + var wrongHash = new string('e', 64); + + var result = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe, + runtimeSelfUpdateTrigger: null); + + Assert.Equal(IntegrityCheckOutcome.Mismatch, result.Outcome); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index e65c5168..6696e6c2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,9 +9,11 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination @@ -44,6 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination public string? LastDiagnosticsSuffix { get; private set; } public DiagnosticsUploadResult? DiagnosticsResult { get; set; } public int ShutdownSignalled; + public List EmittedEvents { get; } = new List(); + public int RebootInvocations; + public int RebootDelaySeconds; + public SessionIdPersistence SessionPersistence { get; set; } = default!; public Rig() { @@ -51,6 +58,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); Packages = new AppPackageStateList(Logger); CleanupService = new RecordingCleanupService(BuildConfig(), Logger); + SessionPersistence = new SessionIdPersistence(Tmp.Path); } public AgentConfiguration BuildConfig( @@ -90,7 +98,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination LastDiagnosticsSuffix = suffix; return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" }); }, - signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled)); + signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled), + analyzerManager: null, + emitEvent: evt => EmittedEvents.Add(evt), + sessionPersistence: SessionPersistence, + triggerReboot: delay => + { + Interlocked.Increment(ref RebootInvocations); + RebootDelaySeconds = delay; + }, + // Zero out the timing ceremony for tests — production paths are covered by + // the dedicated V1-parity tests below which opt back in via their own Rig. + lateEventGracePeriod: TimeSpan.Zero, + spoolDrainPeriod: TimeSpan.Zero); } public void Dispose() => Tmp.Dispose(); @@ -217,5 +237,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination Assert.Equal("failure", rig.LastDiagnosticsSuffix); Assert.Equal(false, rig.LastDiagnosticsSucceededFlag); } + + // ============================================================= PR #50 lifecycle events + + [Fact] + public void Handle_emits_diagnostics_collecting_and_uploaded_events_on_success() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip", SasUrlPrefix = "https://example.test" }; + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting"); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_uploaded" && + ((string)e.Data["blobName"]) == "diag-blob.zip"); + Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed"); + } + + [Fact] + public void Handle_emits_diagnostics_upload_failed_when_upload_returns_null() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + rig.DiagnosticsResult = new DiagnosticsUploadResult { ErrorCode = "upload_5xx" }; + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting"); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed" && + ((string)e.Data["errorCode"]) == "upload_5xx"); + } + + [Fact] + public void Handle_whiteglove_part1_writes_marker_and_emits_whiteglove_part1_complete() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed)); + + // The whiteglove.complete marker must be present so the next boot classifies as Part 2 resume. + Assert.True(rig.SessionPersistence.IsWhiteGloveResume()); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "whiteglove_part1_complete"); + // And — critically — self-destruct MUST have been skipped and the enrollment-complete + // marker MUST NOT be written, or the next Part-2 boot would ghost-detect itself. + Assert.Equal(0, rig.CleanupService.Invocations); + Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + } + + [Fact] + public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: false); + cfg.RebootOnComplete = true; + cfg.RebootDelaySeconds = 15; + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(1, rig.RebootInvocations); + Assert.Equal(15, rig.RebootDelaySeconds); + Assert.Contains(rig.EmittedEvents, e => e.EventType == "reboot_triggered"); + } + + [Fact] + public void Handle_does_not_reboot_when_self_destruct_is_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: true); + cfg.RebootOnComplete = true; + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + // Self-destruct owns the terminal transition; reboot is the no-self-destruct path only. + Assert.Equal(0, rig.RebootInvocations); + Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "reboot_triggered"); + } + + [Fact] + public void Handle_emits_enrollment_summary_shown_when_dialog_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(showDialog: true, selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Contains(rig.EmittedEvents, e => e.EventType == "enrollment_summary_shown"); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs index 4aed3f5e..e55b0dc0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Configuration @@ -17,100 +18,160 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration /// to defaults set from CLI / bootstrap-config, so tenant /// admin settings were effectively ignored. /// - /// CLI-override semantics: command-line flags that were explicitly passed take precedence - /// over the remote value. In production (Scheduled Task run with no args) the remote value - /// always wins; in local dev runs the developer can still force a specific behaviour with - /// a CLI flag without their tenant remote config clobbering it. + /// Merge semantics (V1 parity): remote values OVERWRITE the runtime configuration + /// unconditionally (V1 ApplyRuntimeSettingsFromRemoteConfig). CLI flags seed the + /// initial before Merge runs and then yield to tenant + /// policy — if an operator deploys with --reboot-on-complete but the tenant config + /// disables reboots, the tenant wins. This matches the V1 behaviour and keeps tenants as + /// the source of truth for the knobs that have a remote equivalent. /// /// public static class RemoteConfigMerger { /// /// Overwrites the relevant fields with values from - /// . Unknown / null / empty remote fields keep the - /// current value (partial-remote-config tolerance). + /// . Remote wins unconditionally for every field that has + /// a direct 1:1 mapping. Returns a describing which + /// observable knobs changed so the caller can emit audit events (e.g. + /// agent_unrestricted_mode_changed) after the orchestrator is running. /// - public static void Merge( + public static RemoteConfigMergeResult Merge( AgentConfiguration agentConfig, AgentConfigResponse remoteConfig, - string[] cliArgs) + AgentLogger logger = null) { if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); - if (remoteConfig == null) return; - cliArgs = cliArgs ?? Array.Empty(); + if (remoteConfig == null) return RemoteConfigMergeResult.NoChange(); - // ---- Boolean flags that have a matching CLI override — CLI wins when present. - if (!ContainsFlag(cliArgs, "--no-cleanup")) - agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete; - if (!ContainsFlag(cliArgs, "--keep-logfile")) - agentConfig.KeepLogFile = remoteConfig.KeepLogFile; - if (!ContainsFlag(cliArgs, "--reboot-on-complete")) - agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete; - if (!ContainsFlag(cliArgs, "--disable-geolocation")) - agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation; + var result = new RemoteConfigMergeResult(); - // ---- Log level — CLI `--log-level ` wins. Remote is a string that may not parse. - if (!ContainsValueArg(cliArgs, "--log-level") - && !string.IsNullOrWhiteSpace(remoteConfig.LogLevel) - && Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel)) + // ---------------- Simple scalar flags — remote wins. + agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete; + agentConfig.KeepLogFile = remoteConfig.KeepLogFile; + agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete; + agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation; + + // ---------------- Log level — remote wins, but we parse defensively so an invalid + // remote value keeps the current level (and logs a warning). + result.OldLogLevel = agentConfig.LogLevel; + if (!string.IsNullOrWhiteSpace(remoteConfig.LogLevel)) { - agentConfig.LogLevel = parsedLevel; + if (Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel)) + { + if (parsedLevel != agentConfig.LogLevel) + { + logger?.Info($"Remote config: LogLevel {agentConfig.LogLevel} → {parsedLevel}"); + agentConfig.LogLevel = parsedLevel; + } + } + else + { + logger?.Warning($"Remote config: LogLevel '{remoteConfig.LogLevel}' is not a valid AgentLogLevel — keeping {agentConfig.LogLevel}."); + } } + result.NewLogLevel = agentConfig.LogLevel; + result.LogLevelChanged = result.OldLogLevel != result.NewLogLevel; - // ---- Fields without a CLI override — always apply remote. + // ---------------- Reboot / summary-dialog knobs — direct assignment. agentConfig.RebootDelaySeconds = remoteConfig.RebootDelaySeconds; agentConfig.ShowEnrollmentSummary = remoteConfig.ShowEnrollmentSummary; agentConfig.EnrollmentSummaryTimeoutSeconds = remoteConfig.EnrollmentSummaryTimeoutSeconds; agentConfig.EnrollmentSummaryBrandingImageUrl = remoteConfig.EnrollmentSummaryBrandingImageUrl; agentConfig.EnrollmentSummaryLaunchRetrySeconds = remoteConfig.EnrollmentSummaryLaunchRetrySeconds; - if (!string.IsNullOrWhiteSpace(remoteConfig.NtpServer)) - agentConfig.NtpServer = remoteConfig.NtpServer; + // ---------------- NTP / Timezone — remote wins even when the remote sends null so + // a tenant admin can clear a previously-applied custom NTP server. + agentConfig.NtpServer = remoteConfig.NtpServer; agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet; + // ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off" + // which must clear any CLI-initial default). agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled; - if (!string.IsNullOrWhiteSpace(remoteConfig.DiagnosticsUploadMode)) - agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode; + agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode; agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List(); agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents; - agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode; - // ---- Positive-only int knobs — a zero or negative remote value keeps the current default. - if (remoteConfig.UploadIntervalSeconds > 0) - agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds; + // ---------------- UnrestrictedMode audit — track old/new for the caller so the + // agent_unrestricted_mode_changed event can fire AFTER the + // orchestrator emitter is alive (V1 parity with + // AuditUnrestrictedModeChange in MonitoringService.cs). + result.OldUnrestrictedMode = agentConfig.UnrestrictedMode; + agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode; + result.NewUnrestrictedMode = agentConfig.UnrestrictedMode; + result.UnrestrictedModeChanged = result.OldUnrestrictedMode != result.NewUnrestrictedMode; + + // ---------------- ImeMatchLog: remote ships a bool, V2 runtime uses a file path. + // V1 parity — `true` expands Constants.ImeMatchLogPath, `false` + // nulls the runtime path so the collector disables itself. + // A CLI override (--ime-match-log ) already populated + // ImeMatchLogPath in BuildAgentConfiguration; we only clobber it + // when the remote bool flips off, mirroring V1's unconditional + // assignment with tolerance for the explicit dev override. + if (remoteConfig.EnableImeMatchLog) + { + agentConfig.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + } + else + { + // Only null the path when the user did NOT supply an explicit CLI override. + // A non-null value that does not match the default is a dev-time override. + var defaultPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath); + if (string.IsNullOrEmpty(agentConfig.ImeMatchLogPath) + || string.Equals(agentConfig.ImeMatchLogPath, defaultPath, StringComparison.OrdinalIgnoreCase)) + { + agentConfig.ImeMatchLogPath = null; + } + } + + // ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a + // misconfigured tenant can in theory break uploads, but this + // surfaces the config bug instead of silently masking it). + agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds; if (remoteConfig.MaxBatchSize > 0) agentConfig.MaxBatchSize = remoteConfig.MaxBatchSize; - // ---- Nested: CollectorConfiguration. Only the fields that have a V2 consumer - // on AgentConfiguration are projected here; the rest flow directly from - // remoteConfig.Collectors via DefaultComponentFactory. + // ---------------- AuthFailure ceilings — tracker.UpdateLimits() called by caller + // so the refresh lands on the live watchdog. + agentConfig.MaxAuthFailures = remoteConfig.MaxAuthFailures; + agentConfig.AuthFailureTimeoutMinutes = remoteConfig.AuthFailureTimeoutMinutes; + + // ---------------- Nested CollectorConfiguration — only the two knobs that have a + // V2 consumer on AgentConfiguration itself. The rest flow through + // the untouched remoteConfig.Collectors to DefaultComponentFactory. var collectors = remoteConfig.Collectors; if (collectors != null) { // AgentMaxLifetimeMinutes: 0 = disabled (valid); passed to the orchestrator as a TimeSpan?. agentConfig.AgentMaxLifetimeMinutes = collectors.AgentMaxLifetimeMinutes; + + // HelloWaitTimeoutSeconds: V1 parity — only apply when > 0 so an accidentally + // zero/negative remote value keeps the current non-zero default. V1: + // MonitoringService.ApplyRuntimeSettingsFromRemoteConfig preserves the current + // setting instead of disabling Hello-wait altogether. if (collectors.HelloWaitTimeoutSeconds > 0) agentConfig.HelloWaitTimeoutSeconds = collectors.HelloWaitTimeoutSeconds; } - } - private static bool ContainsFlag(string[] args, string flag) - { - for (int i = 0; i < args.Length; i++) - if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) - return true; - return false; - } - - private static bool ContainsValueArg(string[] args, string flag) - { - // Value-args need both the flag and a subsequent token, otherwise the CLI helper would - // not have parsed them — mirror GetArgValue's "i < args.Length - 1" contract. - for (int i = 0; i < args.Length - 1; i++) - if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) - return true; - return false; + return result; } } + + /// + /// Observable side-effects of . The caller uses this + /// to decide whether to emit audit events / reconfigure live components (logger, watchdog) + /// after the orchestrator is alive. + /// + public sealed class RemoteConfigMergeResult + { + public bool UnrestrictedModeChanged { get; set; } + public bool OldUnrestrictedMode { get; set; } + public bool NewUnrestrictedMode { get; set; } + + public bool LogLevelChanged { get; set; } + public AgentLogLevel OldLogLevel { get; set; } + public AgentLogLevel NewLogLevel { get; set; } + + public static RemoteConfigMergeResult NoChange() => new RemoteConfigMergeResult(); + } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs index 7915daa2..d5770b01 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs @@ -83,18 +83,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration { _logger.Warning($"Config fetch authentication failed: {ex.Message}"); - // Auth failure → use distress channel (pre-auth, works without cert) - if (_distressReporter != null) - { - _ = _distressReporter.TrySendAsync( - DistressErrorType.ConfigFetchDenied, - ex.Message, - httpStatusCode: ex.StatusCode); - } - - // Feed the central auth-failure tracker so the agent can shut down cleanly - // after MaxAuthFailures / AuthFailureTimeoutMinutes is exceeded, instead of - // hammering the backend forever when the device certificate is revoked. + // V1 parity — AuthFailureTracker is the single dispatch point for auth-failure + // distress signals (see HandleAuthFailure in EventUploadOrchestrator). It sends + // the first-failure report via its constructor-injected DistressReporter with + // the correct DistressErrorType per status code (401 → AuthCertificateRejected, + // 403 → DeviceNotRegistered) and suppresses duplicates on subsequent failures. _authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config"); } catch (Exception ex) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index fbd80b40..ee087f5f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -192,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration onEnrollmentEvent: onEnrollmentEvent, logger: logger, rules: _remoteConfig.GatherRules, - imeLogPathOverride: _agentConfig.ImeLogPathOverride)); + imeLogPathOverride: _agentConfig.ImeLogPathOverride, + unrestrictedMode: _agentConfig.UnrestrictedMode)); } return hosts; @@ -209,6 +210,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly Monitoring.Telemetry.Gather.GatherRuleExecutor _executor; private readonly System.Collections.Generic.List _rules; private readonly AgentLogger _logger; + private readonly bool _unrestrictedMode; private int _disposed; public GatherRuleExecutorHost( @@ -217,18 +219,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration Action onEnrollmentEvent, AgentLogger logger, System.Collections.Generic.List rules, - string? imeLogPathOverride) + string? imeLogPathOverride, + bool unrestrictedMode = false) { _logger = logger; _rules = rules ?? new System.Collections.Generic.List(); + _unrestrictedMode = unrestrictedMode; _executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor( sessionId, tenantId, onEnrollmentEvent, logger, imeLogPathOverride); } public void Start() { + // V1 parity (CollectorCoordinator.StartGatherRuleExecutor) — propagate the + // tenant-controlled UnrestrictedMode BEFORE UpdateRules so any startup-trigger + // rule sees the elevated policy when AllowList checks would otherwise reject it. + _executor.UnrestrictedMode = _unrestrictedMode; _executor.UpdateRules(_rules); - _logger.Info($"GatherRuleExecutorHost: started with {_rules.Count} rule(s)."); + _logger.Info( + $"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}."); } public void Stop() diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs index 801a8e5e..cfa07083 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs @@ -1,7 +1,9 @@ using System; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Security { @@ -18,6 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// AgentConfigResponse (defaults: 5 consecutive, time window disabled). /// /// + /// V1 parity (EventUploadOrchestrator.HandleAuthFailure): the tracker is the single + /// dispatch point for distress reports. The first failure emits a distress signal + /// via the optional with + /// for 401 and + /// for 403. Subsequent failures are + /// logged but do not spam the distress channel — there is no point asking the backend + /// twice about a certificate it already rejected. + /// + /// /// Thread-safety: consecutive-count uses ; the first-failure timestamp /// is protected by a single monitor lock. is raised outside the /// lock to prevent handler re-entrancy deadlocks. @@ -27,6 +38,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security { private readonly IClock _clock; private readonly AgentLogger _logger; + private readonly DistressReporter _distressReporter; private readonly object _windowLock = new object(); private int _maxFailures; // 0 = disabled @@ -39,10 +51,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security int maxFailures, int timeoutMinutes, IClock clock, - AgentLogger logger) + AgentLogger logger, + DistressReporter distressReporter = null) { _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _distressReporter = distressReporter; UpdateLimits(maxFailures, timeoutMinutes); } @@ -67,8 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } /// - /// Record a 401/403 from the given . Emits - /// the first time either ceiling is crossed; subsequent calls after termination is armed are no-ops. + /// Record a 401/403 from the given . V1 parity — on the FIRST + /// failure this fires a single distress report via the constructor-injected + /// (401 → AuthCertificateRejected, 403 → DeviceNotRegistered). + /// Subsequent failures are logged + counted but do not dispatch further distress signals. + /// Emits the first time either ceiling is crossed; + /// subsequent calls after termination is armed are no-ops. /// public void RecordFailure(int statusCode, string operation) { @@ -84,12 +102,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Security firstFailureAt = _firstFailureUtc.Value; } + // V1 parity — only the first consecutive failure produces a distress report. Repeat + // hits at 2..N use the local log only; the backend already knows from the first + // report that the device is unauthorized. + if (count == 1 && _distressReporter != null) + { + var distressType = statusCode == 403 + ? DistressErrorType.DeviceNotRegistered + : DistressErrorType.AuthCertificateRejected; + try + { + _ = _distressReporter.TrySendAsync( + distressType, + $"Backend returned {statusCode} during {operation}", + httpStatusCode: statusCode); + } + catch (Exception ex) + { + _logger.Debug($"AuthFailureTracker: distress dispatch threw: {ex.Message}"); + } + } + bool countExceeded = _maxFailures > 0 && count >= _maxFailures; bool windowExceeded = _timeoutWindow.HasValue && (now - firstFailureAt) >= _timeoutWindow.Value; if (!countExceeded && !windowExceeded) { - _logger.Warning($"AuthFailureTracker: consecutive auth failure #{count} (http {statusCode}, {operation})."); + _logger.Warning( + $"Authentication failure {count}/{_maxFailures} (first failure at {firstFailureAt:HH:mm:ss}, " + + $"http {statusCode}, {operation})."); return; } @@ -99,7 +140,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security ? $"consecutive auth failures reached limit ({count} >= {_maxFailures})" : $"auth-failure time window exceeded ({(now - firstFailureAt).TotalMinutes:F0}min >= {_timeoutWindow.Value.TotalMinutes:F0}min)"; - _logger.Error($"AuthFailureTracker: {reason} — signalling shutdown. Last operation: {operation}, statusCode={statusCode}."); + _logger.Error( + $"=== AGENT SHUTDOWN: {count} consecutive authentication failures (401/403) — " + + "backend rejecting requests. Terminating agent to prevent distress spam. ==="); + _logger.Error($"AuthFailureTracker: {reason}. Last operation: {operation}, statusCode={statusCode}."); try { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs index 202fe1a4..a8794ec4 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs @@ -1,8 +1,10 @@ using System; +using System.Diagnostics; using System.IO; -using System.Reflection; using System.Security.Cryptography; using System.Text; +using System.Threading; +using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; namespace AutopilotMonitor.Agent.V2.Core.Security @@ -17,35 +19,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// self-update failed silently — a previous update swapped the ZIP but not the EXE. /// /// - /// We intentionally do not terminate or auto-update from this check: - /// already runs at startup and its hash-based trigger covers the "wrong EXE" case. All we do - /// here is emit an AgentErrorType.IntegrityCheckFailed report so operators see the drift. + /// V1 parity: on mismatch an / AgentErrorType.IntegrityCheckFailed + /// report is emitted by the caller, and — if is wired — + /// is invoked in force-update mode to auto-heal. + /// The trigger is single-shot per agent process ( guard) so a noisy + /// config fetch cannot drive the update pipeline multiple times in a row. /// /// public static class BinaryIntegrityVerifier { + private static int _runtimeSelfUpdateFired; + /// - /// Computes the SHA-256 of (or the currently executing assembly - /// when null) and compares it against . + /// Computes the SHA-256 of (or the current process' main-module + /// image when null) and compares it against . /// Case-insensitive comparison (both lowercase and uppercase hex strings accepted). + /// + /// When is non-null and the hashes mismatch, + /// fires the trigger once per agent process — typically wired to + /// SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate: true, triggerReason: "runtime_hash_mismatch"). + /// /// + /// Backend-advertised SHA-256 (lowercase hex preferred). + /// Agent logger. Match uses Info, mismatch uses Error (V1 parity). + /// Optional explicit path. null = use Process.GetCurrentProcess().MainModule.FileName. + /// Optional auto-heal hook. Params: (zipHash, allowDowngrade) → Task. + /// Value of AgentConfigResponse.LatestAgentSha256 — forwarded to the trigger. + /// Value of AgentConfigResponse.AllowAgentDowngrade — forwarded to the trigger. public static IntegrityCheckResult Check( string expectedSha256, AgentLogger logger, - string exePath = null) + string exePath = null, + Func runtimeSelfUpdateTrigger = null, + string zipHash = null, + bool allowDowngrade = false) { if (logger == null) throw new ArgumentNullException(nameof(logger)); if (string.IsNullOrWhiteSpace(expectedSha256)) { - logger.Debug("BinaryIntegrityVerifier: no expected hash provided — skipping check."); + logger.Debug("Post-config integrity check: no expected hash provided — skipping."); return IntegrityCheckResult.Skipped(); } - var path = exePath ?? Assembly.GetExecutingAssembly().Location; + var path = exePath ?? ResolveMainModulePath(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { - logger.Warning($"BinaryIntegrityVerifier: executing assembly path not resolvable ({path ?? "null"}) — skipping check."); + logger.Warning($"Post-config integrity check: cannot resolve EXE path ({path ?? "null"}) — skipping."); return IntegrityCheckResult.Skipped(); } @@ -56,22 +76,79 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } catch (Exception ex) { - logger.Warning($"BinaryIntegrityVerifier: failed to hash '{path}': {ex.Message}"); + logger.Warning($"Post-config integrity check: hashing failed — {ex.Message}. Skipping."); return IntegrityCheckResult.Skipped(); } if (string.Equals(actualSha, expectedSha256, StringComparison.OrdinalIgnoreCase)) { - logger.Debug($"BinaryIntegrityVerifier: SHA-256 match (sha={Truncate(actualSha)})."); + logger.Info($"Post-config integrity check: SHA-256 verified OK ({Truncate(actualSha)})."); return IntegrityCheckResult.Match(actualSha); } - logger.Warning( - $"BinaryIntegrityVerifier: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, backend advertises={Truncate(expectedSha256)}. " + - "Possible tamper, stale blob, or failed self-update."); + logger.Error( + $"Post-config integrity check: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, " + + $"backend advertises={Truncate(expectedSha256)} — possible tamper, stale blob, or failed self-update."); + + TriggerRuntimeSelfUpdate(logger, runtimeSelfUpdateTrigger, zipHash, allowDowngrade); + return IntegrityCheckResult.Mismatch(actualSha, expectedSha256); } + /// Exposed for tests — resets the single-shot trigger so each test starts clean. + internal static void ResetTriggerForTests() => Interlocked.Exchange(ref _runtimeSelfUpdateFired, 0); + + private static void TriggerRuntimeSelfUpdate( + AgentLogger logger, + Func runtimeSelfUpdateTrigger, + string zipHash, + bool allowDowngrade) + { + if (runtimeSelfUpdateTrigger == null) + { + logger.Warning("Runtime self-update trigger not wired — mismatch reported but no auto-heal possible."); + return; + } + + if (Interlocked.Exchange(ref _runtimeSelfUpdateFired, 1) == 1) + { + logger.Debug("Runtime self-update trigger already fired this session — ignoring."); + return; + } + + // Run the auto-heal on the thread pool — SelfUpdater.CheckAndApplyUpdateAsync + // blocks for the version/ZIP fetch and, on success, calls Environment.Exit before + // returning, so the awaiter never resolves. Do NOT block the caller on this. + Task.Run(async () => + { + try + { + logger.Info($"Runtime self-update trigger: invoking SelfUpdater (forceUpdate=true, allowDowngrade={allowDowngrade})."); + await runtimeSelfUpdateTrigger(zipHash, allowDowngrade).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Error("Runtime self-update trigger failed.", ex); + } + }); + } + + private static string ResolveMainModulePath() + { + try + { + // V1 parity — MonitoringService.VerifyAgentBinaryIntegrity uses + // Process.GetCurrentProcess().MainModule.FileName, NOT Assembly.Location. + // Under ngen / single-file publish scenarios MainModule resolves to the real + // on-disk image while Assembly.Location can be empty or point at a shadow copy. + return Process.GetCurrentProcess().MainModule?.FileName; + } + catch + { + return null; + } + } + internal static string ComputeSha256Hex(string filePath) { using (var stream = File.OpenRead(filePath)) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs index e9fb5325..a1e4b15c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs @@ -132,6 +132,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security lock (_lockObject) return File.Exists(_whiteGloveMarkerPath); } + /// + /// V1 parity (SessionPersistence.SaveWhiteGloveComplete) — persists the + /// whiteglove.complete marker so the next agent boot recognises this as a + /// Part-2 resume (). Without this marker the + /// emergency-break watchdog would keep counting the session's wall-clock age through + /// the Part-1/Part-2 power-off window and eventually trip during Part-2 setup. + /// + public void SaveWhiteGloveComplete(AgentLogger logger = null) + { + lock (_lockObject) + { + try + { + File.WriteAllText(_whiteGloveMarkerPath, DateTime.UtcNow.ToString("O")); + logger?.Info("SessionIdPersistence: whiteglove.complete marker saved — Part 2 detection enabled."); + } + catch (Exception ex) + { + logger?.Warning($"SessionIdPersistence: SaveWhiteGloveComplete failed: {ex.Message}"); + } + } + } + + /// + /// Clears the whiteglove.complete marker once Part-2 resumes so a subsequent + /// session does not re-enter the resume code path. + /// + public void ClearWhiteGloveComplete(AgentLogger logger = null) + { + lock (_lockObject) TryDelete(_whiteGloveMarkerPath, logger); + } + /// Deletes the persisted SessionId. Next starts a fresh session. public void Delete(AgentLogger logger = null) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index 9edff8da..253e13c0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -8,8 +10,10 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Termination { @@ -17,28 +21,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination /// Orchestrates peripheral termination work in response to /// . Plan §4.x M4.6.β. /// - /// Sequence (Legacy parity — single-writer, best-effort each step, never throws): + /// Sequence (V1 parity — single-writer, best-effort each step, never throws): /// /// + /// Run shutdown analyzers (optional) so their delta events land before diagnostics. /// Compose via . - /// Write final-status.json + launch SummaryDialog via . - /// Upload diagnostics via (respects - /// DiagnosticsUploadMode: Off / Always / OnFailure). - /// Write enrollment-complete.marker (so ghost-restart detection on next boot - /// exits cleanly even if failed). + /// Write final-status.json + launch if configured. + /// Emit enrollment_summary_shown event once the dialog has been handed to the user session. + /// 2s grace so late events can land before the next step. + /// Emit diagnostics_collecting → upload diagnostics → emit diagnostics_uploaded / diagnostics_upload_failed. + /// Write enrollment-complete.marker (ghost-restart guard on next boot). + /// Standalone-reboot path: if RebootOnComplete && !SelfDestructOnComplete emit + /// reboot_triggered, drain spool, call shutdown.exe /r /t <delay>. /// Run — UNLESS the stage is - /// (Part 1 exit, session resumes Part 2). - /// Signal the caller-owned shutdown so Program.cs' - /// main loop exits the shutdown.Wait() and calls orchestrator.Stop(). + /// (Part-1 exit, session resumes Part 2). + /// WhiteGlove Part-1 path: emit whiteglove_part1_complete, drain spool, and + /// write whiteglove.complete marker via + /// so Part-2 resume is detected on the next boot. + /// Signal the caller-owned shutdown . /// /// /// Each step logs + continues on failure — nothing in here is allowed to prevent the agent - /// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script - /// that waits for process exit); this handler does not block waiting for the script. + /// from shutting down. is fire-and-forget (spawns a PowerShell + /// cleanup script that waits for process exit); this handler does not block waiting for it. /// /// public sealed class EnrollmentTerminationHandler { + private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000); + private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000); // V1 parity: up to 20 × 500ms drains before shutdown.exe + private readonly AgentConfiguration _configuration; private readonly AgentLogger _logger; private readonly string _stateDirectory; @@ -50,6 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination private readonly Action _signalShutdown; private readonly string _dialogExePathOverride; private readonly AgentAnalyzerManager _analyzerManager; + private readonly Action _emitEvent; + private readonly SessionIdPersistence _sessionPersistence; + private readonly Action _triggerReboot; + private readonly TimeSpan _lateEventGracePeriod; + private readonly TimeSpan _spoolDrainPeriod; private int _handled; @@ -64,7 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination Func> uploadDiagnosticsAsync, Action signalShutdown, string dialogExePathOverride = null, - AgentAnalyzerManager analyzerManager = null) + AgentAnalyzerManager analyzerManager = null, + Action emitEvent = null, + SessionIdPersistence sessionPersistence = null, + Action triggerReboot = null, + TimeSpan? lateEventGracePeriod = null, + TimeSpan? spoolDrainPeriod = null) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -77,6 +99,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown)); _dialogExePathOverride = dialogExePathOverride; _analyzerManager = analyzerManager; + _emitEvent = emitEvent; + _sessionPersistence = sessionPersistence; + _triggerReboot = triggerReboot ?? DefaultTriggerReboot; + _lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace; + _spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain; } /// @@ -86,6 +113,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination { if (Interlocked.Exchange(ref _handled, 1) == 1) return; + var isWhiteGlovePart1 = args.StageName == SessionStage.WhiteGloveSealed.ToString(); + try { _logger.Info( @@ -107,8 +136,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination RunBuildAndLaunchDialog(state, args); } - RunUploadDiagnostics(args); + // WhiteGlove Part-1 exit: keep the session alive, but announce the handoff so + // the timeline clearly marks the transition. The `whiteglove.complete` marker + // lets the next agent boot classify itself as a Part-2 resume. + if (isWhiteGlovePart1) + { + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "whiteglove_part1_complete", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "WhiteGlove Part 1 complete — device will seal for end-user.", + ImmediateUpload = true, + }); + + DelayLateEventGrace(); + DrainSpool(); + + TrySaveWhiteGloveComplete(); + return; + } + + DelayLateEventGrace(); + + RunUploadDiagnosticsWithEvents(args); WriteEnrollmentCompleteMarker(args); + RunStandaloneRebootIfRequested(); RunSelfDestructIfAppropriate(args); } catch (Exception ex) @@ -166,6 +222,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination var packages = TryGetPackageStates(); var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc); SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride); + + if (_configuration.ShowEnrollmentSummary) + { + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "enrollment_summary_shown", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "Enrollment summary dialog shown to user.", + Data = new Dictionary + { + { "totalApps", status?.AppSummary?.TotalApps ?? 0 }, + { "errorCount", status?.AppSummary?.ErrorCount ?? 0 }, + { "outcome", status?.Outcome ?? string.Empty }, + { "timeoutSeconds", _configuration.EnrollmentSummaryTimeoutSeconds }, + }, + ImmediateUpload = true, + }); + } } catch (Exception ex) { @@ -173,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination } } - private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args) + private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args) { var mode = _configuration.DiagnosticsUploadMode ?? "Off"; if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) @@ -189,19 +267,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination return; } + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_collecting", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = "Collecting diagnostics package.", + Data = new Dictionary + { + { "mode", mode }, + { "enrollmentSucceeded", enrollmentSucceeded }, + }, + ImmediateUpload = true, + }); + + DiagnosticsUploadResult result = null; try { var suffix = enrollmentSucceeded ? "success" : "failure"; - var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); - if (result != null && result.Success) - _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); - else - _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}."); + result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); } catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}"); } + + if (result != null && result.Success) + { + _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_uploaded", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Diagnostics package uploaded ({result.BlobName}).", + Data = new Dictionary + { + { "blobName", result.BlobName ?? string.Empty }, + { "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + else + { + var errorCode = result?.ErrorCode ?? "null-result"; + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}."); + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "diagnostics_upload_failed", + Severity = EventSeverity.Warning, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Diagnostics upload failed: {errorCode}.", + Data = new Dictionary + { + { "errorCode", errorCode }, + { "blobName", result?.BlobName ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } } private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args) @@ -228,6 +362,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination } } + /// + /// V1 parity — standalone-reboot flow. When the tenant config disables self-destruct + /// but enables RebootOnComplete, the agent's final act is shutdown.exe /r + /// with the configured delay, giving the user a visible countdown. + /// + private void RunStandaloneRebootIfRequested() + { + if (_configuration.SelfDestructOnComplete) return; + if (!_configuration.RebootOnComplete) return; + + var delay = _configuration.RebootDelaySeconds > 0 ? _configuration.RebootDelaySeconds : 10; + + EmitEventSafe(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "reboot_triggered", + Severity = EventSeverity.Info, + Source = "EnrollmentTerminationHandler", + Phase = EnrollmentPhase.Unknown, + Message = $"Standalone reboot triggered (delay={delay}s).", + Data = new Dictionary + { + { "rebootDelaySeconds", delay }, + { "selfDestructOnComplete", _configuration.SelfDestructOnComplete }, + }, + ImmediateUpload = true, + }); + + DrainSpool(); + + try + { + _triggerReboot(delay); + _logger.Info($"EnrollmentTerminationHandler: standalone reboot queued via shutdown.exe /r /t {delay}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: standalone reboot invocation failed: {ex.Message}"); + } + } + private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args) { if (!_configuration.SelfDestructOnComplete) @@ -253,5 +429,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex); } } + + private void TrySaveWhiteGloveComplete() + { + if (_sessionPersistence == null) + { + _logger.Warning("EnrollmentTerminationHandler: sessionPersistence not wired — whiteglove.complete marker NOT written (Part-2 detection will fail)."); + return; + } + + try { _sessionPersistence.SaveWhiteGloveComplete(_logger); } + catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: SaveWhiteGloveComplete threw: {ex.Message}"); } + } + + private void DelayLateEventGrace() + { + if (_lateEventGracePeriod <= TimeSpan.Zero) return; + try { Task.Delay(_lateEventGracePeriod).Wait(); } + catch { /* best-effort */ } + } + + private void DrainSpool() + { + // V1 parity — block briefly so pending events can land before the next destructive + // step (shutdown.exe, self-destruct). A precise HasPending/PendingCount signal is + // internal to the orchestrator transport, so the V2 equivalent is a bounded wait. + if (_spoolDrainPeriod <= TimeSpan.Zero) return; + try { Task.Delay(_spoolDrainPeriod).Wait(); } + catch { /* best-effort */ } + } + + private void EmitEventSafe(EnrollmentEvent evt) + { + if (_emitEvent == null) return; + try { _emitEvent(evt); } + catch (Exception ex) { _logger.Debug($"EnrollmentTerminationHandler: event emission '{evt?.EventType}' threw: {ex.Message}"); } + } + + private static void DefaultTriggerReboot(int delaySeconds) + { + var psi = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"), + Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + Process.Start(psi); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index ad4dea54..d61bbc23 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -206,12 +206,19 @@ namespace AutopilotMonitor.Agent.V2 // set by --install, no session.id yet) as a ghost restart and trigger // ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData // directory on every fresh deployment. + var sessionPersistence = new SessionIdPersistence(dataDirectory); if (args.Contains("--new-session")) { - new SessionIdPersistence(dataDirectory).Delete(logger); + sessionPersistence.Delete(logger); logger.Info("--new-session: cleared persisted SessionId."); } - agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger); + // Snapshot the WhiteGlove-resume state BEFORE GetOrCreate: on Part-2 resume we want + // to emit the whiteglove_resumed event AFTER orchestrator.Start. Reading the marker + // up front avoids racing any downstream clear. V1 parity — the Part-2 detection is + // marker-based, and the agent announces resume on the session timeline so that + // dashboards can correlate the session's two boots. + var isWhiteGloveResume = sessionPersistence.IsWhiteGloveResume(); + agentConfig.SessionId = sessionPersistence.GetOrCreate(logger); // Build a cleanup-service factory used by guards: instantiated lazily and with the // current agentConfig so command-line overrides (e.g. --no-cleanup) take effect. @@ -310,11 +317,14 @@ namespace AutopilotMonitor.Agent.V2 // defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge // so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the // shutdown signal is available, to trigger a clean agent exit when the limit is hit. + // V1 parity — the distress reporter is plumbed in at construction so the tracker is + // the single dispatch point for auth-failure distress (first failure only). var authFailureTracker = new AuthFailureTracker( maxFailures: agentConfig.MaxAuthFailures, timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes, clock: SystemClock.Instance, - logger: logger); + logger: logger, + distressReporter: distressReporter); var remoteConfigService = new RemoteConfigService( backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker); @@ -323,8 +333,10 @@ namespace AutopilotMonitor.Agent.V2 // Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that // downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes, // DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog) - // actually respect the tenant admin settings. CLI flags stay authoritative over remote. - RemoteConfigMerger.Merge(agentConfig, remoteConfig, args); + // actually respect the tenant admin settings. V1 parity — remote wins + // unconditionally for every knob that has a 1:1 mapping. CLI flags seed the initial + // AgentConfiguration in BuildAgentConfiguration and then yield to tenant policy. + var configMergeResult = RemoteConfigMerger.Merge(agentConfig, remoteConfig, logger); // Refresh tracker ceilings with the tenant-specific values we just merged in. authFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes); @@ -338,10 +350,32 @@ namespace AutopilotMonitor.Agent.V2 SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256; // Post-config binary-integrity check: verify the running EXE's SHA-256 against the - // value advertised by the backend. Mismatch does not terminate (SelfUpdater's own - // hash-based trigger covers the "wrong EXE" case at next startup) — we just emit an - // IntegrityCheckFailed error report so operators see the drift. - var integrityResult = BinaryIntegrityVerifier.Check(remoteConfig.LatestAgentExeSha256, logger); + // value advertised by the backend. V1 parity — on mismatch we (a) emit an + // IntegrityCheckFailed emergency report and (b) fire the runtime self-update trigger + // so the agent auto-heals (SelfUpdater.CheckAndApplyUpdateAsync force-update path). + // The trigger is single-shot per process (Interlocked guard inside the verifier). + var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + Func runtimeSelfUpdateTrigger = (zipHash, downgrade) => + { + if (!string.IsNullOrEmpty(zipHash)) + SelfUpdater.BackendExpectedSha256 = zipHash; + + return SelfUpdater.CheckAndApplyUpdateAsync( + currentVersion: GetAgentVersion(), + agentDir: agentDirForTrigger, + consoleMode: consoleMode, + forceUpdate: true, + triggerReason: "runtime_hash_mismatch", + downloadTimeoutMsOverride: 60000, + allowDowngrade: downgrade); + }; + + var integrityResult = BinaryIntegrityVerifier.Check( + expectedSha256: remoteConfig.LatestAgentExeSha256, + logger: logger, + runtimeSelfUpdateTrigger: runtimeSelfUpdateTrigger, + zipHash: remoteConfig.LatestAgentSha256, + allowDowngrade: remoteConfig.AllowAgentDowngrade); if (integrityResult.IsMismatch) { _ = emergencyReporter.TrySendAsync( @@ -461,7 +495,9 @@ namespace AutopilotMonitor.Agent.V2 cleanupServiceFactory: () => new CleanupService(agentConfig, logger), uploadDiagnosticsAsync: uploadDiagnosticsAsync, signalShutdown: () => shutdown.Set(), - analyzerManager: analyzerManager); + analyzerManager: analyzerManager, + emitEvent: evt => orchestrator.EventEmitter.Emit(evt), + sessionPersistence: sessionPersistence); // ServerActionDispatcher — handlers are live and the M4.6.ε response-parse // path below feeds it. RotateConfig refetches remote config; @@ -522,14 +558,85 @@ namespace AutopilotMonitor.Agent.V2 Console.CancelKeyPress += cancelHandler; AppDomain.CurrentDomain.ProcessExit += processExitHandler; + + // V1 parity — on the max-lifetime watchdog firing, emit an explicit + // `enrollment_failed` event with failureType=agent_timeout BEFORE the + // regular termination path runs. Dashboards + KQL queries key on the + // event type + data dictionary to distinguish a genuine enrollment failure + // from a timeout-triggered shutdown. + EventHandler maxLifetimeEmitter = (s, e) => + { + if (e.Reason != EnrollmentTerminationReason.MaxLifetimeExceeded) return; + try + { + var uptimeMin = (DateTime.UtcNow - agentStartTimeUtc).TotalMinutes; + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "enrollment_failed", + Severity = EventSeverity.Error, + Source = "EnrollmentOrchestrator", + Phase = EnrollmentPhase.Complete, + Message = $"Agent max lifetime expired ({uptimeMin:F0} min) — enrollment did not complete in time", + Data = new System.Collections.Generic.Dictionary + { + { "failureType", "agent_timeout" }, + { "failureSource", "max_lifetime_timer" }, + { "agentUptimeMinutes", Math.Round(uptimeMin, 1) }, + { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes }, + { "stageAtTimeout", e.StageName ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception emitEx) + { + logger.Warning($"enrollment_failed (max_lifetime) emission failed: {emitEx.Message}"); + } + }; + orchestrator.Terminated += maxLifetimeEmitter; orchestrator.Terminated += terminationHandler.Handle; // Auth-failure watchdog: when MaxAuthFailures / AuthFailureTimeoutMinutes // is exceeded the agent must shut down cleanly instead of hammering a // backend that has definitely said no. Event fires at most once. + // V1 parity — emit a structured `agent_shutdown` event with reason=auth_failure + // and the full telemetry payload before tripping the shutdown signal so the + // backend sees WHY the agent terminated in the session timeline. EventHandler authThresholdHandler = (s, e) => { logger.Error($"Auth-failure threshold exceeded ({e.Reason}) — initiating shutdown."); + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_shutdown", + Severity = EventSeverity.Error, + Source = "AuthFailureTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent shut down after {e.ConsecutiveFailures} consecutive auth failures", + Data = new System.Collections.Generic.Dictionary + { + { "reason", "auth_failure" }, + { "consecutiveFailures", e.ConsecutiveFailures }, + { "firstFailureTime", e.FirstFailureUtc.ToString("o") }, + { "maxFailures", agentConfig.MaxAuthFailures }, + { "timeoutMinutes", agentConfig.AuthFailureTimeoutMinutes }, + { "lastOperation", e.LastOperation ?? string.Empty }, + { "lastStatusCode", e.LastStatusCode }, + { "thresholdReason", e.Reason ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception emitEx) + { + logger.Warning($"agent_shutdown emission failed: {emitEx.Message}"); + } + shutdown.Set(); }; authFailureTracker.ThresholdExceeded += authThresholdHandler; @@ -554,6 +661,28 @@ namespace AutopilotMonitor.Agent.V2 // VersionCheckEventBuilder.TryBuild is a no-op when no markers are present. EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger); + // V1 parity (MonitoringService.AuditUnrestrictedModeChange) — whenever the + // remote tenant admin flips the UnrestrictedMode guardrail, emit an audit + // event so the change is visible in the session timeline and downstream + // alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive. + EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger); + + // V1 parity — agent_started / whiteglove_resumed / system_reboot_detected + // lifecycle events. These are purely informational (Phase=Unknown) but the + // session timeline and downstream alerting depend on them to classify the + // boot. + EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger); + if (isWhiteGloveResume) + { + EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger); + try { sessionPersistence.ClearWhiteGloveComplete(logger); } + catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); } + } + if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase)) + { + EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger); + } + // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / // IntegrityBypass). Runs on a background task inside AgentAnalyzerManager. try { analyzerManager.RunStartup(); } @@ -586,6 +715,7 @@ namespace AutopilotMonitor.Agent.V2 Console.CancelKeyPress -= cancelHandler; AppDomain.CurrentDomain.ProcessExit -= processExitHandler; orchestrator.Terminated -= terminationHandler.Handle; + orchestrator.Terminated -= maxLifetimeEmitter; authFailureTracker.ThresholdExceeded -= authThresholdHandler; try { orchestrator.Stop(); } @@ -671,6 +801,176 @@ namespace AutopilotMonitor.Agent.V2 }; } + /// + /// V1 parity — when flips the tenant-controlled + /// UnrestrictedMode guardrail, surface the transition as an auditable event on + /// the session timeline so operators can correlate subsequent gather-rule exec with the + /// elevated policy. The V1 code lives in + /// MonitoringService.AuditUnrestrictedModeChange. + /// + private static void EmitUnrestrictedModeAuditIfChanged( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + RemoteConfigMergeResult mergeResult, + AgentLogger logger) + { + if (mergeResult == null || !mergeResult.UnrestrictedModeChanged) return; + + try + { + var newValue = mergeResult.NewUnrestrictedMode; + logger.Warning( + $"UnrestrictedMode changed: {mergeResult.OldUnrestrictedMode} → {newValue}. Emitting audit event."); + + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_unrestricted_mode_changed", + Severity = newValue ? EventSeverity.Warning : EventSeverity.Info, + Source = "RemoteConfigMerger", + Phase = EnrollmentPhase.Unknown, + Message = newValue + ? "Agent unrestricted mode ENABLED — gather rules can now execute without AllowList checks" + : "Agent unrestricted mode disabled — gather rules revert to AllowList checks", + Data = new System.Collections.Generic.Dictionary + { + { "oldValue", mergeResult.OldUnrestrictedMode }, + { "newValue", newValue }, + { "changedAtUtc", DateTime.UtcNow.ToString("o") }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"EmitUnrestrictedModeAuditIfChanged: emission failed: {ex.Message}"); + } + } + + /// + /// V1 parity — fire-and-forget agent_started event emitted after + /// . Carries a snapshot of the boot classification + /// () and the tenant-influenced runtime knobs so dashboards + /// can classify crash-loops, backend-rejected sessions and forced self-destruct runs. + /// Phase stays — the event is NOT a phase declaration. + /// + private static void EmitAgentStartedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + PreviousExitSummary previousExit, + AgentLogger logger) + { + try + { + var data = new System.Collections.Generic.Dictionary + { + { "agentVersion", GetAgentVersion() }, + { "commandLineArgs", agentConfig.CommandLineArgs ?? string.Empty }, + { "isBootstrapSession", agentConfig.UseBootstrapTokenAuth }, + { "awaitEnrollment", agentConfig.AwaitEnrollment }, + { "selfDestructOnComplete", agentConfig.SelfDestructOnComplete }, + { "certAuth", !agentConfig.UseBootstrapTokenAuth }, + { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes }, + { "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" }, + { "previousExitType", previousExit?.ExitType ?? "unknown" }, + { "unrestrictedMode", agentConfig.UnrestrictedMode }, + }; + + if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType)) + data["previousCrashException"] = previousExit.CrashExceptionType; + + if (previousExit?.LastBootUtc.HasValue == true) + data["previousBootUtc"] = previousExit.LastBootUtc.Value.ToString("o"); + + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "agent_started", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent v{GetAgentVersion()} started (previousExit={previousExit?.ExitType ?? "unknown"}).", + Data = data, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"agent_started emission failed: {ex.Message}"); + } + } + + /// + /// Emits whiteglove_resumed when the whiteglove.complete marker was present at + /// boot — signals that the agent has resumed Part 2 after the device reseal. + /// + private static void EmitWhiteGloveResumedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + AgentLogger logger) + { + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "whiteglove_resumed", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = "WhiteGlove Part 2 resume detected — continuing user-phase monitoring.", + Data = new System.Collections.Generic.Dictionary + { + { "resumedAtUtc", DateTime.UtcNow.ToString("o") }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"whiteglove_resumed emission failed: {ex.Message}"); + } + } + + /// + /// Emits system_reboot_detected when the previous agent exit was classified as + /// reboot_kill — the OS rebooted mid-session. Gives the backend a hook to split + /// the session into pre-reboot and post-reboot segments. + /// + private static void EmitSystemRebootDetectedEvent( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + PreviousExitSummary previousExit, + AgentLogger logger) + { + try + { + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = "system_reboot_detected", + Severity = EventSeverity.Info, + Source = "AutopilotMonitor.Agent.V2", + Phase = EnrollmentPhase.Unknown, + Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.", + Data = new System.Collections.Generic.Dictionary + { + { "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty }, + { "previousExitType", previousExit?.ExitType ?? string.Empty }, + }, + ImmediateUpload = true, + }); + } + catch (Exception ex) + { + logger.Warning($"system_reboot_detected emission failed: {ex.Message}"); + } + } + private static void EmitVersionCheckEventIfAny( EnrollmentOrchestrator orchestrator, AgentConfiguration agentConfig, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index c739b42c..f906e184 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -44 +96 From bcd3c231683f6e388686c7259d1b3216f6cbc1a9 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 13:26:26 +0200 Subject: [PATCH 2/6] feat(agent-v2): register-session handshake + periodic-collector idle mgmt + network-change + DO handler-chain fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining critical V1 parity gaps uncovered by the audit. Without these, the V2 agent would accept events into /api/agent/telemetry but the backend Sessions table would never get a row (so every UpdateSessionStatusAsync / IncrementSession silently no-ops), PerformanceCollector + AgentSelfMetricsCollector would run for the entire session lifetime filling the spool, network transitions would be invisible, and the DO collector would overwrite newer handlers on shutdown. PR #51 — Session registration (V1 MonitoringService.RegisterSessionAsync) - New SessionRegistrationHelper with 5-retry + V1 exponential backoff (2s/4s/8s/16s). Auth failures (401/403) short-circuit via AuthFailureTracker.RecordFailure ("agent/register-session") — which already fires first-failure distress with AuthCertificateRejected / DeviceNotRegistered. Other exceptions on the last attempt trigger EmergencyReporter with AgentErrorType.RegisterSessionFailed. - New EnrollmentRegistryDetector — V1-parity registry probes for CloudAssignedDomainJoinMethod (hybrid join) and CloudAssignedDeviceRegistration / CloudAssignedEspEnabled (v1 vs v2 / WDP) so SessionRegistration carries the same EnrollmentType / IsHybridJoin values V1 sent. - Program.cs calls RegisterWithRetryAsync after BackendTelemetryUploader is ready and BEFORE orchestrator.Start. On non-Succeeded outcome, the agent exits cleanly (exit code 6 for auth, 7 for other) instead of spinning up collectors for an unregistered session — V1 parity: "=== SESSION REGISTRATION FAILED — collectors will NOT start ===" - BackendApiClient.RegisterSessionAsync is now `virtual` so test doubles can intercept the HTTP call. - Admin-override preemption: when the register response carries AdminAction ("Succeeded"/"Failed") we emit the matching enrollment_complete/_failed event and synthesise a Terminated so the termination handler runs cleanup immediately — V1 parity with MonitoringService.cs:388-413. Periodic-collector idle lifecycle (V1 PeriodicCollectorManager) - New PeriodicCollectorLifecycleHost owns PerformanceCollector + AgentSelfMetricsCollector under a single CollectorIdleTimeoutMinutes window (default 15). Stops both after N minutes of no real enrollment activity and restarts them on the next real event. Without this the V2 agent kept polling perf counters every 60s for the agent's full lifetime on dormant sessions. - Activity observation: the host wraps the onEnrollmentEvent sink passed to its internal collectors so events with "performance_snapshot" / "agent_metrics_snapshot" / "stall_probe_*" / "session_stalled" types do NOT count as activity — only real events reset the idle timer. - Emits performance_collector_stopped / agent_metrics_collector_stopped on idle-stop (V1 parity event types + data schema). Network-change wiring - NetworkChangeDetector existed in V2 but was never instantiated. New NetworkChangeHost wires it into DefaultComponentFactory so WiFi SSID / default-route / MDM-endpoint-reachability transitions are emitted as network_change events — V1 parity with CollectorCoordinator.StartOptionalCollectors:375-382. DeliveryOptimizationHost Stop() bug - V1 comparison `ReferenceEquals(x.OnAppStateChanged, x.OnAppStateChanged)` was a tautology (always true) — silently overwrote any newer handler that had taken over the slot. Fixed by capturing the chained handler reference and comparing against it instead. Tests - 481/481 V2 tests green (+5 new SessionRegistrationHelper tests covering the 5-retry happy path, retry-then-success, auth short-circuit, exhaustion after 5 exceptions, and registration payload correctness). - 708/708 Backend tests untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SessionRegistrationHelperTests.cs | 167 ++++++++ .../Monitoring/Transport/BackendApiClient.cs | 9 +- .../Orchestration/DefaultComponentFactory.cs | 356 +++++++++++++++--- .../Security/EnrollmentRegistryDetector.cs | 75 ++++ .../Security/SessionRegistrationHelper.cs | 194 ++++++++++ .../AutopilotMonitor.Agent.V2/Program.cs | 102 +++++ .../buildcounter.txt | 2 +- 7 files changed, 847 insertions(+), 58 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs 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/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 ee087f5f..7ea05d3e 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). /// /// /// @@ -154,17 +158,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 @@ -516,33 +540,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"; @@ -579,13 +576,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}"); } @@ -597,6 +596,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)."); @@ -605,9 +605,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(); } @@ -618,33 +624,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/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/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/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index d61bbc23..567362bd 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -16,6 +16,7 @@ using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; @@ -429,6 +430,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(), @@ -683,6 +712,22 @@ namespace AutopilotMonitor.Agent.V2 EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger); } + // V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup" + // block) — if the register-session response carried an AdminAction the + // session was already marked terminal by an operator via the portal. Emit + // the matching lifecycle event and synthesise a Terminated so the normal + // termination pipeline (cleanup + summary + self-destruct) runs. The + // orchestrator has only just started so the event ordering is clean. + if (!string.IsNullOrEmpty(registrationResult.AdminAction)) + { + HandleStartupAdminOverride( + orchestrator, + terminationHandler, + agentConfig, + registrationResult.AdminAction, + logger); + } + // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / // IntegrityBypass). Runs on a background task inside AgentAnalyzerManager. try { analyzerManager.RunStartup(); } @@ -848,6 +893,63 @@ namespace AutopilotMonitor.Agent.V2 } } + /// + /// V1 parity (MonitoringService.cs:388-413) — when the register-session response + /// carried an AdminAction ("Succeeded" / "Failed"), emit the matching + /// enrollment_complete / enrollment_failed event and synthesise a + /// so the termination pipeline (cleanup, + /// summary dialog, self-destruct) runs without waiting for an external signal. + /// + private static void HandleStartupAdminOverride( + EnrollmentOrchestrator orchestrator, + EnrollmentTerminationHandler terminationHandler, + AgentConfiguration agentConfig, + string adminAction, + AgentLogger logger) + { + try + { + var succeeded = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); + logger.Warning( + $"=== ADMIN OVERRIDE on startup: session already marked as {adminAction} by administrator — running cleanup only ==="); + + orchestrator.EventEmitter.Emit(new EnrollmentEvent + { + SessionId = agentConfig.SessionId, + TenantId = agentConfig.TenantId, + EventType = succeeded ? "enrollment_complete" : "enrollment_failed", + Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, + Source = "AdminOverride", + Phase = EnrollmentPhase.Complete, + Message = $"Session {adminAction.ToLowerInvariant()} by administrator (detected on restart) — cleanup initiated.", + Data = new System.Collections.Generic.Dictionary + { + { "adminAction", adminAction }, + { "source", "register_session_response" }, + }, + ImmediateUpload = true, + }); + + var outcome = succeeded + ? EnrollmentTerminationOutcome.Succeeded + : EnrollmentTerminationOutcome.Failed; + var stage = succeeded ? SessionStage.Completed : SessionStage.Failed; + + terminationHandler.Handle( + sender: null, + args: new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.DecisionTerminalStage, + outcome: outcome, + stageName: stage.ToString(), + terminatedAtUtc: DateTime.UtcNow, + details: $"Admin marked session as {adminAction} via portal (detected on register-session).")); + } + catch (Exception ex) + { + logger.Warning($"HandleStartupAdminOverride failed: {ex.Message}"); + } + } + /// /// V1 parity — fire-and-forget agent_started event emitted after /// . Carries a snapshot of the boot classification diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index f906e184..29d6383b 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -96 +100 From a63512d1390a0ac997c7cf2d11dff097a80d7391 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 15:52:49 +0200 Subject: [PATCH 3/6] feat(agent-v2): post SessionStarted signal after register-session (V2 reducer anchor) V2 parity lack: EnrollmentOrchestrator.Start never synthesised a SessionStarted signal, so HandleSessionStartedV1 (DecisionEngine.Shared.cs:36) was unreachable in production. DecisionState.Stage stayed at Unknown for the entire session lifetime while raw signals (ESP / Hello / etc.) landed on an uninitialised state. The reducer tolerated this silently, but it left us no session-anchor timestamp and no early stage-classification hook. Program.cs now posts SessionStarted directly after register-session success, with the tenant-registered metadata (EnrollmentType, IsHybridJoin, ValidatedBy, ...) as signal payload. Skipped on WhiteGlove Part-2 resume (orchestrator posts SessionRecovered instead) and on admin preemption (subsequent AdminPreemption signal drives straight to terminal; see next commit). Follow-up: the reducer handler currently only bumps StepIndex - a later patch should have it consume the enrollmentType/isHybridJoin payload for flow routing. Logged in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Program/PostSessionStartedSignalTests.cs | 125 ++++++++++++++++++ .../AutopilotMonitor.Agent.V2/Program.cs | 55 ++++++++ .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs 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/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 567362bd..3c2cd019 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -16,6 +16,7 @@ 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; @@ -712,6 +713,17 @@ namespace AutopilotMonitor.Agent.V2 EmitSystemRebootDetectedEvent(orchestrator, agentConfig, 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 — the "ADMIN OVERRIDE on startup" // block) — if the register-session response carried an AdminAction the // session was already marked terminal by an operator via the portal. Emit @@ -950,6 +962,49 @@ namespace AutopilotMonitor.Agent.V2 } } + /// + /// 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 diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 29d6383b..257e5632 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -100 +102 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 7e179677..e255a855 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1071 +1072 From 5b7f424d190b0a347228ae455c9e22487bc64744 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 16:00:25 +0200 Subject: [PATCH 4/6] refactor(agent-v2): route whiteglove_resumed / system_reboot_detected / admin preemption through reducer effects V2 parity cleanup - the three lifecycle events were emitted from Program.cs directly while the corresponding reducer handlers (HandleWhiteGlovePart1To2Bridge, HandleSystemRebootObservedV1) returned Array.Empty(). Admin preemption synthesised an EnrollmentTerminatedEventArgs and invoked terminationHandler.Handle directly, bypassing the reducer entirely. This commit unifies all three flows: the reducer is the single state owner, the telemetry event appears as an EmitEventTimelineEntry effect of the state transition, and the orchestrator's Terminated event fires automatically when stage goes terminal - no more synthetic bypass. - B1: HandleWhiteGlovePart1To2Bridge emits whiteglove_resumed timeline entry. - B2: HandleSystemRebootObservedV1 emits system_reboot_detected with payload keys previousExitType/lastBootUtc. - B3: new DecisionSignalKind.AdminPreemptionDetected + new SessionOutcome.AdminPreempted. Reducer HandleAdminPreemptionDetectedV1 transitions to Completed/Failed based on adminOutcome payload, emits enrollment_complete/_failed timeline entry with adminAction+source data keys. Program.cs posts the signal; terminationHandler still subscribes to orchestrator.Terminated (no direct Handle calls on the startup-preemption path). Fixtures: tests/fixtures/signal-kinds/{system-reboot-observed,admin-preemption-detected}-v1.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AutopilotMonitor.Agent.V2/Program.cs | 182 ++++++---------- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../ReducerTimelineEffectTests.cs | 196 ++++++++++++++++++ .../Engine/DecisionEngine.Edge.cs | 26 ++- .../Engine/DecisionEngine.Shared.cs | 58 ++++++ .../Engine/DecisionEngine.WhiteGlovePart2.cs | 8 + .../Engine/DecisionEngine.cs | 9 +- .../Signals/DecisionSignalKind.cs | 3 + .../State/SessionOutcome.cs | 7 + .../admin-preemption-detected-v1-failed.json | 3 + ...dmin-preemption-detected-v1-succeeded.json | 4 + .../system-reboot-observed-v1.json | 5 + 13 files changed, 382 insertions(+), 123 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs create mode 100644 tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json create mode 100644 tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json create mode 100644 tests/fixtures/signal-kinds/system-reboot-observed-v1.json diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 3c2cd019..0550e794 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -697,20 +697,26 @@ namespace AutopilotMonitor.Agent.V2 // alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive. EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger); - // V1 parity — agent_started / whiteglove_resumed / system_reboot_detected - // lifecycle events. These are purely informational (Phase=Unknown) but the - // session timeline and downstream alerting depend on them to classify the - // boot. + // 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) { - EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger); 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)) { - EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger); + PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger); } // V2 parity — post SessionStarted so the reducer establishes the session anchor @@ -724,20 +730,18 @@ namespace AutopilotMonitor.Agent.V2 PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger); } - // V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup" - // block) — if the register-session response carried an AdminAction the - // session was already marked terminal by an operator via the portal. Emit - // the matching lifecycle event and synthesise a Terminated so the normal - // termination pipeline (cleanup + summary + self-destruct) runs. The - // orchestrator has only just started so the event ordering is clean. + // 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)) { - HandleStartupAdminOverride( - orchestrator, - terminationHandler, - agentConfig, - registrationResult.AdminAction, - logger); + PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger); } // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / @@ -906,59 +910,40 @@ namespace AutopilotMonitor.Agent.V2 } /// - /// V1 parity (MonitoringService.cs:388-413) — when the register-session response - /// carried an AdminAction ("Succeeded" / "Failed"), emit the matching - /// enrollment_complete / enrollment_failed event and synthesise a - /// so the termination pipeline (cleanup, - /// summary dialog, self-destruct) runs without waiting for an external signal. + /// 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 HandleStartupAdminOverride( - EnrollmentOrchestrator orchestrator, - EnrollmentTerminationHandler terminationHandler, - AgentConfiguration agentConfig, - string adminAction, + private static void PostAdminPreemptionSignal( + ISignalIngressSink ingressSink, + SessionRegistrationResult registrationResult, AgentLogger logger) { try { - var succeeded = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); + var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed" logger.Warning( - $"=== ADMIN OVERRIDE on startup: session already marked as {adminAction} by administrator — running cleanup only ==="); + $"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ==="); - orchestrator.EventEmitter.Emit(new EnrollmentEvent - { - SessionId = agentConfig.SessionId, - TenantId = agentConfig.TenantId, - EventType = succeeded ? "enrollment_complete" : "enrollment_failed", - Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, - Source = "AdminOverride", - Phase = EnrollmentPhase.Complete, - Message = $"Session {adminAction.ToLowerInvariant()} by administrator (detected on restart) — cleanup initiated.", - Data = new System.Collections.Generic.Dictionary + 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) { - { "adminAction", adminAction }, - { "source", "register_session_response" }, - }, - ImmediateUpload = true, - }); - - var outcome = succeeded - ? EnrollmentTerminationOutcome.Succeeded - : EnrollmentTerminationOutcome.Failed; - var stage = succeeded ? SessionStage.Completed : SessionStage.Failed; - - terminationHandler.Handle( - sender: null, - args: new EnrollmentTerminatedEventArgs( - reason: EnrollmentTerminationReason.DecisionTerminalStage, - outcome: outcome, - stageName: stage.ToString(), - terminatedAtUtc: DateTime.UtcNow, - details: $"Admin marked session as {adminAction} via portal (detected on register-session).")); + ["adminOutcome"] = adminOutcome, + }); } catch (Exception ex) { - logger.Warning($"HandleStartupAdminOverride failed: {ex.Message}"); + logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}"); } } @@ -1060,71 +1045,38 @@ namespace AutopilotMonitor.Agent.V2 } /// - /// Emits whiteglove_resumed when the whiteglove.complete marker was present at - /// boot — signals that the agent has resumed Part 2 after the device reseal. + /// 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 EmitWhiteGloveResumedEvent( - EnrollmentOrchestrator orchestrator, - AgentConfiguration agentConfig, - AgentLogger logger) - { - try - { - orchestrator.EventEmitter.Emit(new EnrollmentEvent - { - SessionId = agentConfig.SessionId, - TenantId = agentConfig.TenantId, - EventType = "whiteglove_resumed", - Severity = EventSeverity.Info, - Source = "AutopilotMonitor.Agent.V2", - Phase = EnrollmentPhase.Unknown, - Message = "WhiteGlove Part 2 resume detected — continuing user-phase monitoring.", - Data = new System.Collections.Generic.Dictionary - { - { "resumedAtUtc", DateTime.UtcNow.ToString("o") }, - }, - ImmediateUpload = true, - }); - } - catch (Exception ex) - { - logger.Warning($"whiteglove_resumed emission failed: {ex.Message}"); - } - } - - /// - /// Emits system_reboot_detected when the previous agent exit was classified as - /// reboot_kill — the OS rebooted mid-session. Gives the backend a hook to split - /// the session into pre-reboot and post-reboot segments. - /// - private static void EmitSystemRebootDetectedEvent( - EnrollmentOrchestrator orchestrator, - AgentConfiguration agentConfig, + private static void PostSystemRebootObservedSignal( + ISignalIngressSink ingressSink, PreviousExitSummary previousExit, AgentLogger logger) { try { - orchestrator.EventEmitter.Emit(new EnrollmentEvent + var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal) { - SessionId = agentConfig.SessionId, - TenantId = agentConfig.TenantId, - EventType = "system_reboot_detected", - Severity = EventSeverity.Info, - Source = "AutopilotMonitor.Agent.V2", - Phase = EnrollmentPhase.Unknown, - Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.", - Data = new System.Collections.Generic.Dictionary - { - { "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty }, - { "previousExitType", previousExit?.ExitType ?? string.Empty }, - }, - ImmediateUpload = true, - }); + ["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($"system_reboot_detected emission failed: {ex.Message}"); + logger.Warning($"SystemRebootObserved post failed: {ex.Message}"); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 257e5632..b16e5f75 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -102 +104 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index e255a855..bba0bc80 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1072 +1073 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"}} From f149eef3fe757f1e3f3079fda9e3221be8ed499a Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 16:06:58 +0200 Subject: [PATCH 5/6] feat(agent-v2): wire --replay-log-dir CLI flag to ImeLogTracker SimulationMode V2 had the complete replay infrastructure in place but never wired up: ImeLogTracker.SimulationMode / SpeedFactor properties (ImeLogTracker.cs:66-67) and the ApplySimulationDelay compression loop (ImeLogTracker.Handlers.cs:529-546) were fully implemented, and AgentConfiguration had ReplayLogDir + ReplaySpeedFactor fields (L.128, L.134), but nothing set them from the CLI and nothing read them in the factory. This commit adds: - CLI args --replay-log-dir and --replay-speed-factor in Program.BuildAgentConfiguration (invariant-culture parse, zero/malformed rejected). - DefaultComponentFactory.ImeLogHost ctor accepts a simulation mode + speed factor and applies them to the inner tracker when ReplayLogDir is non-empty. - PrintUsage documents the flags under a new "Dev / Test" section. For the deterministic signal-level replay (V2 ReplayHarness + signal-log.jsonl fixtures) no wiring is needed - that is already the primary V2 test path. The IME- log replay activated here is the V1 compatibility dev-mode for support cases where only raw IME logs are available. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BuildAgentConfigurationReplayTests.cs | 66 +++++++++++++++++++ .../Orchestration/DefaultComponentFactory.cs | 31 ++++++++- .../AutopilotMonitor.Agent.V2/Program.cs | 22 ++++++- .../buildcounter.txt | 2 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs 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/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 7ea05d3e..aa8c319c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -126,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, @@ -133,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) @@ -401,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, @@ -412,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!; @@ -428,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); diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 0550e794..db01c975 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -1113,7 +1113,7 @@ namespace AutopilotMonitor.Agent.V2 // ---------------------------------------------------------------- Configuration - private static AgentConfiguration BuildAgentConfiguration( + internal static AgentConfiguration BuildAgentConfiguration( string[] args, string tenantId, string sessionId, @@ -1124,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; @@ -1169,6 +1183,8 @@ namespace AutopilotMonitor.Agent.V2 KeepLogFile = keepLogFile, SelfDestructOnComplete = !noCleanup, CommandLineArgs = FormatArgsForLog(args), + ReplayLogDir = replayLogDir, + ReplaySpeedFactor = replaySpeedFactor, }; } @@ -1269,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 b16e5f75..fe4afb0d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -104 +106 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index bba0bc80..cf635ffb 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1073 +1074 From 1bb659cac56e202be357efd404a56db890fd3c3c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 22 Apr 2026 17:01:03 +0200 Subject: [PATCH 6/6] chore: bump build counters after V2 parity refactor tests --- src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt | 2 +- src/Agent/AutopilotMonitor.Agent/buildcounter.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index fe4afb0d..e34885bb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -106 +107 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index cf635ffb..69559761 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1074 +1075