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