feat(agent-v2): V1-parity remediation — lifecycle events, auth/binary watchdogs, remote config

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 <delay>.
- 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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-22 12:10:05 +02:00
parent ddacaf29ce
commit fc778b4154
13 changed files with 1304 additions and 245 deletions
@@ -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
{
/// <summary>
/// Tests for <see cref="RemoteConfigMerger"/>. This merger is the single source of
/// Legacy-parity: every field delivered by the backend that has a live V2 consumer must
/// land on <see cref="AgentConfiguration"/> after <c>FetchConfigAsync</c>.
/// V1-parity: every field delivered by the backend that has a live V2 consumer must land
/// on <see cref="AgentConfiguration"/> after <c>FetchConfigAsync</c>.
/// <para>
/// V1 parity: remote values OVERWRITE the runtime configuration unconditionally. CLI
/// flags only seed the initial <see cref="AgentConfiguration"/> and yield to tenant
/// policy once Merge runs. This is asserted by a regression test below.
/// </para>
/// </summary>
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<string>());
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<string>());
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<string>());
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 <custom> 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<string>());
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<string>());
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<string>());
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<string>());
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<string>());
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<string>());
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<string>());
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<string>());
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<string>());
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<ArgumentNullException>(() =>
RemoteConfigMerger.Merge(null!, FullRemote(), Array.Empty<string>()));
}
// ============================================================= 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<string>());
Assert.False(agent.SelfDestructOnComplete);
RemoteConfigMerger.Merge(null!, FullRemote()));
}
}
}
@@ -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<Call> Calls { get; } = new List<Call>();
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; }
@@ -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<string, bool, Task> 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<string, bool, Task> 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);
}
}
}
@@ -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<EnrollmentEvent> EmittedEvents { get; } = new List<EnrollmentEvent>();
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");
}
}
}
@@ -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 <see cref="AgentConfiguration"/> defaults set from CLI / bootstrap-config, so tenant
/// admin settings were effectively ignored.
/// <para>
/// 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.
/// <b>Merge semantics (V1 parity):</b> remote values OVERWRITE the runtime configuration
/// unconditionally (V1 <c>ApplyRuntimeSettingsFromRemoteConfig</c>). CLI flags seed the
/// initial <see cref="AgentConfiguration"/> before Merge runs and then yield to tenant
/// policy — if an operator deploys with <c>--reboot-on-complete</c> 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.
/// </para>
/// </summary>
public static class RemoteConfigMerger
{
/// <summary>
/// Overwrites the relevant <paramref name="agentConfig"/> fields with values from
/// <paramref name="remoteConfig"/>. Unknown / null / empty remote fields keep the
/// current <paramref name="agentConfig"/> value (partial-remote-config tolerance).
/// <paramref name="remoteConfig"/>. Remote wins unconditionally for every field that has
/// a direct 1:1 mapping. Returns a <see cref="RemoteConfigMergeResult"/> describing which
/// observable knobs changed so the caller can emit audit events (e.g.
/// <c>agent_unrestricted_mode_changed</c>) after the orchestrator is running.
/// </summary>
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<string>();
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 <value>` wins. Remote is a string that may not parse.
if (!ContainsValueArg(cliArgs, "--log-level")
&& !string.IsNullOrWhiteSpace(remoteConfig.LogLevel)
&& Enum.TryParse<AgentLogLevel>(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<AgentLogLevel>(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<DiagnosticsLogPath>();
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 <path>) 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;
}
}
/// <summary>
/// Observable side-effects of <see cref="RemoteConfigMerger.Merge"/>. The caller uses this
/// to decide whether to emit audit events / reconfigure live components (logger, watchdog)
/// after the orchestrator is alive.
/// </summary>
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();
}
}
@@ -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)
@@ -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<AutopilotMonitor.Shared.Models.GatherRule> _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<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
System.Collections.Generic.List<AutopilotMonitor.Shared.Models.GatherRule> rules,
string? imeLogPathOverride)
string? imeLogPathOverride,
bool unrestrictedMode = false)
{
_logger = logger;
_rules = rules ?? new System.Collections.Generic.List<AutopilotMonitor.Shared.Models.GatherRule>();
_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()
@@ -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
/// <c>AgentConfigResponse</c> (defaults: 5 consecutive, time window disabled).
/// </para>
/// <para>
/// V1 parity (<c>EventUploadOrchestrator.HandleAuthFailure</c>): the tracker is the single
/// dispatch point for distress reports. The <b>first</b> failure emits a distress signal
/// via the optional <see cref="DistressReporter"/> with
/// <see cref="DistressErrorType.AuthCertificateRejected"/> for 401 and
/// <see cref="DistressErrorType.DeviceNotRegistered"/> 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.
/// </para>
/// <para>
/// Thread-safety: consecutive-count uses <see cref="Interlocked"/>; the first-failure timestamp
/// is protected by a single monitor lock. <see cref="ThresholdExceeded"/> 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
}
/// <summary>
/// Record a 401/403 from the given <paramref name="operation"/>. Emits <see cref="ThresholdExceeded"/>
/// the first time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — on the FIRST
/// failure this fires a single distress report via the constructor-injected
/// <see cref="DistressReporter"/> (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
/// Subsequent failures are logged + counted but do not dispatch further distress signals.
/// Emits <see cref="ThresholdExceeded"/> the first time either ceiling is crossed;
/// subsequent calls after termination is armed are no-ops.
/// </summary>
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
{
@@ -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
/// <item>self-update failed silently — a previous update swapped the ZIP but not the EXE.</item>
/// </list>
/// <para>
/// We intentionally do <b>not</b> terminate or auto-update from this check: <see cref="SelfUpdater"/>
/// already runs at startup and its hash-based trigger covers the "wrong EXE" case. All we do
/// here is emit an <c>AgentErrorType.IntegrityCheckFailed</c> report so operators see the drift.
/// V1 parity: on mismatch an <see cref="EmergencyReporter"/> / <c>AgentErrorType.IntegrityCheckFailed</c>
/// report is emitted by the caller, and — if <paramref name="runtimeSelfUpdateTrigger"/> is wired —
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> is invoked in force-update mode to auto-heal.
/// The trigger is single-shot per agent process (<see cref="Interlocked"/> guard) so a noisy
/// config fetch cannot drive the update pipeline multiple times in a row.
/// </para>
/// </summary>
public static class BinaryIntegrityVerifier
{
private static int _runtimeSelfUpdateFired;
/// <summary>
/// Computes the SHA-256 of <paramref name="exePath"/> (or the currently executing assembly
/// when <c>null</c>) and compares it against <paramref name="expectedSha256"/>.
/// Computes the SHA-256 of <paramref name="exePath"/> (or the current process' main-module
/// image when <c>null</c>) and compares it against <paramref name="expectedSha256"/>.
/// Case-insensitive comparison (both lowercase and uppercase hex strings accepted).
/// <para>
/// When <paramref name="runtimeSelfUpdateTrigger"/> is non-null and the hashes mismatch,
/// fires the trigger once per agent process — typically wired to
/// <c>SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate: true, triggerReason: "runtime_hash_mismatch")</c>.
/// </para>
/// </summary>
/// <param name="expectedSha256">Backend-advertised SHA-256 (lowercase hex preferred).</param>
/// <param name="logger">Agent logger. Match uses Info, mismatch uses Error (V1 parity).</param>
/// <param name="exePath">Optional explicit path. <c>null</c> = use <c>Process.GetCurrentProcess().MainModule.FileName</c>.</param>
/// <param name="runtimeSelfUpdateTrigger">Optional auto-heal hook. Params: <c>(zipHash, allowDowngrade) → Task</c>.</param>
/// <param name="zipHash">Value of <c>AgentConfigResponse.LatestAgentSha256</c> — forwarded to the trigger.</param>
/// <param name="allowDowngrade">Value of <c>AgentConfigResponse.AllowAgentDowngrade</c> — forwarded to the trigger.</param>
public static IntegrityCheckResult Check(
string expectedSha256,
AgentLogger logger,
string exePath = null)
string exePath = null,
Func<string, bool, Task> 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);
}
/// <summary>Exposed for tests — resets the single-shot trigger so each test starts clean.</summary>
internal static void ResetTriggerForTests() => Interlocked.Exchange(ref _runtimeSelfUpdateFired, 0);
private static void TriggerRuntimeSelfUpdate(
AgentLogger logger,
Func<string, bool, Task> 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))
@@ -132,6 +132,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
lock (_lockObject) return File.Exists(_whiteGloveMarkerPath);
}
/// <summary>
/// V1 parity (<c>SessionPersistence.SaveWhiteGloveComplete</c>) — persists the
/// <c>whiteglove.complete</c> marker so the next agent boot recognises this as a
/// Part-2 resume (<see cref="IsWhiteGloveResume"/>). 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.
/// </summary>
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}");
}
}
}
/// <summary>
/// Clears the <c>whiteglove.complete</c> marker once Part-2 resumes so a subsequent
/// session does not re-enter the resume code path.
/// </summary>
public void ClearWhiteGloveComplete(AgentLogger logger = null)
{
lock (_lockObject) TryDelete(_whiteGloveMarkerPath, logger);
}
/// <summary>Deletes the persisted SessionId. Next <see cref="GetOrCreate"/> starts a fresh session.</summary>
public void Delete(AgentLogger logger = null)
{
@@ -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
/// <see cref="EnrollmentOrchestrator.Terminated"/>. Plan §4.x M4.6.β.
/// <para>
/// <b>Sequence</b> (Legacy parity — single-writer, best-effort each step, never throws):
/// <b>Sequence</b> (V1 parity — single-writer, best-effort each step, never throws):
/// </para>
/// <list type="number">
/// <item>Run shutdown analyzers (optional) so their delta events land before diagnostics.</item>
/// <item>Compose <see cref="FinalStatus"/> via <see cref="FinalStatusBuilder"/>.</item>
/// <item>Write final-status.json + launch SummaryDialog via <see cref="SummaryDialogLauncher"/>.</item>
/// <item>Upload diagnostics via <see cref="DiagnosticsPackageService"/> (respects
/// <c>DiagnosticsUploadMode</c>: Off / Always / OnFailure).</item>
/// <item>Write <c>enrollment-complete.marker</c> (so ghost-restart detection on next boot
/// exits cleanly even if <see cref="CleanupService"/> failed).</item>
/// <item>Write final-status.json + launch <see cref="SummaryDialogLauncher"/> if configured.</item>
/// <item>Emit <c>enrollment_summary_shown</c> event once the dialog has been handed to the user session.</item>
/// <item><see cref="Task.Delay(int)"/> 2s grace so late events can land before the next step.</item>
/// <item>Emit <c>diagnostics_collecting</c> → upload diagnostics → emit <c>diagnostics_uploaded</c> / <c>diagnostics_upload_failed</c>.</item>
/// <item>Write <c>enrollment-complete.marker</c> (ghost-restart guard on next boot).</item>
/// <item>Standalone-reboot path: if <c>RebootOnComplete &amp;&amp; !SelfDestructOnComplete</c> emit
/// <c>reboot_triggered</c>, drain spool, call <c>shutdown.exe /r /t &lt;delay&gt;</c>.</item>
/// <item>Run <see cref="CleanupService.ExecuteSelfDestruct"/> — UNLESS the stage is
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part 1 exit, session resumes Part 2).</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/> so Program.cs'
/// main loop exits the <c>shutdown.Wait()</c> and calls <c>orchestrator.Stop()</c>.</item>
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part-1 exit, session resumes Part 2).</item>
/// <item>WhiteGlove Part-1 path: emit <c>whiteglove_part1_complete</c>, drain spool, and
/// write <c>whiteglove.complete</c> marker via <see cref="SessionIdPersistence.SaveWhiteGloveComplete"/>
/// so Part-2 resume is detected on the next boot.</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/>.</item>
/// </list>
/// <para>
/// 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. <see cref="CleanupService"/> is fire-and-forget (spawns a PowerShell
/// cleanup script that waits for process exit); this handler does not block waiting for it.
/// </para>
/// </summary>
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<EnrollmentEvent> _emitEvent;
private readonly SessionIdPersistence _sessionPersistence;
private readonly Action<int> _triggerReboot;
private readonly TimeSpan _lateEventGracePeriod;
private readonly TimeSpan _spoolDrainPeriod;
private int _handled;
@@ -64,7 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Func<bool, string, Task<DiagnosticsUploadResult>> uploadDiagnosticsAsync,
Action signalShutdown,
string dialogExePathOverride = null,
AgentAnalyzerManager analyzerManager = null)
AgentAnalyzerManager analyzerManager = null,
Action<EnrollmentEvent> emitEvent = null,
SessionIdPersistence sessionPersistence = null,
Action<int> 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;
}
/// <summary>
@@ -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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "errorCode", errorCode },
{ "blobName", result?.BlobName ?? string.Empty },
},
ImmediateUpload = true,
});
}
}
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
@@ -228,6 +362,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
/// <summary>
/// V1 parity — standalone-reboot flow. When the tenant config disables self-destruct
/// but enables <c>RebootOnComplete</c>, the agent's final act is <c>shutdown.exe /r</c>
/// with the configured delay, giving the user a visible countdown.
/// </summary>
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<string, object>
{
{ "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);
}
}
}
+310 -10
View File
@@ -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<string, bool, Task> 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<EnrollmentTerminatedEventArgs> 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<string, object>
{
{ "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<AuthFailureThresholdEventArgs> 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<string, object>
{
{ "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
};
}
/// <summary>
/// V1 parity — when <see cref="RemoteConfigMerger"/> flips the tenant-controlled
/// <c>UnrestrictedMode</c> 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
/// <c>MonitoringService.AuditUnrestrictedModeChange</c>.
/// </summary>
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<string, object>
{
{ "oldValue", mergeResult.OldUnrestrictedMode },
{ "newValue", newValue },
{ "changedAtUtc", DateTime.UtcNow.ToString("o") },
},
ImmediateUpload = true,
});
}
catch (Exception ex)
{
logger.Warning($"EmitUnrestrictedModeAuditIfChanged: emission failed: {ex.Message}");
}
}
/// <summary>
/// V1 parity — fire-and-forget <c>agent_started</c> event emitted after
/// <see cref="EnrollmentOrchestrator.Start"/>. Carries a snapshot of the boot classification
/// (<paramref name="previousExit"/>) and the tenant-influenced runtime knobs so dashboards
/// can classify crash-loops, backend-rejected sessions and forced self-destruct runs.
/// Phase stays <see cref="EnrollmentPhase.Unknown"/> — the event is NOT a phase declaration.
/// </summary>
private static void EmitAgentStartedEvent(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
PreviousExitSummary previousExit,
AgentLogger logger)
{
try
{
var data = new System.Collections.Generic.Dictionary<string, object>
{
{ "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}");
}
}
/// <summary>
/// Emits <c>whiteglove_resumed</c> when the whiteglove.complete marker was present at
/// boot — signals that the agent has resumed Part 2 after the device reseal.
/// </summary>
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<string, object>
{
{ "resumedAtUtc", DateTime.UtcNow.ToString("o") },
},
ImmediateUpload = true,
});
}
catch (Exception ex)
{
logger.Warning($"whiteglove_resumed emission failed: {ex.Message}");
}
}
/// <summary>
/// Emits <c>system_reboot_detected</c> when the previous agent exit was classified as
/// <c>reboot_kill</c> — the OS rebooted mid-session. Gives the backend a hook to split
/// the session into pre-reboot and post-reboot segments.
/// </summary>
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<string, object>
{
{ "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,
@@ -1 +1 @@
44
96