mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+166
-126
@@ -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; }
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
-1
@@ -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)
|
||||
{
|
||||
|
||||
+243
-19
@@ -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 && !SelfDestructOnComplete</c> emit
|
||||
/// <c>reboot_triggered</c>, drain spool, call <c>shutdown.exe /r /t <delay></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user